diff --git a/.env b/.env index 72eba97..ec4d988 100644 --- a/.env +++ b/.env @@ -1,7 +1,7 @@ # General application settings APP_NAME=codeword HTTP_HOST="localhost" -HTTP_PORT="8000" +HTTP_PORT="8080" # MongoDB settings MONGO_HOST="127.0.0.1" @@ -17,11 +17,13 @@ REDIS_PASS="admin" REDIS_DB=2 # Keys -PUBLIC_CURVE_KEY="-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyt4XuLovUY7i12K2PIMbQZOKn+wFFKUvxvKQDel049/+VMpHMx1FLolUKuyGp9zi6gOwjHsBPgc9oqr/eaXGQSh7Ult7i9f+Ht563Y0er5UU9Zc5ZPSxf9O75KYD48ruGkqiFoncDqPENK4dtUa7w0OqlN4bwVBbmIsP8B3EDC5Dof+vtiNTSHSXPx+zifKeZGyknp+nyOHVrRDhPjOhzQzCom0MSZA/sJYmps8QZgiPA0k4Z6jTupDymPOIwYeD2C57zSxnAv0AfC3/pZYJbZYH/0TszRzmy052DME3zMnhMK0ikdN4nzYqU0dkkA5kb5GtKDymspHIJ9eWbUuwgtg8Rq/LrVBj1I3UFgs0ibio40k6gqinLKslc5Y1I5mro7J3OSEP5eO/XeDLOLlOJjEqkrx4fviI1cL3m5L6QV905xmcoNZG1+RmOg7D7cZQUf27TXqM381jkbNdktm1JLTcMScxuo3vaRftnIVw70V8P8sIkaKY8S8HU1sQgE2LB9t04oog5u59htx2FHv4B13NEm8tt8Tv1PexpB4UVh7PIualF6SxdFBrKbraYej72wgjXVPQ0eGXtGGD57j8DUEzk7DK2OvIWhehlVqtiRnFdAvdBj2ynHT2/5FJ/Zpd4n5dKGJcQvy1U1qWMs+8M7AHfWyt2+nZ04s48+bK3yMCAwEAAQ==\n-----END PUBLIC KEY-----" +PUBLIC_CURVE_KEY="-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAEbnIvjIMle4rqVol6K2XUqOxHy1KJoNoZdKJrRUPKL4=\n-----END PUBLIC KEY-----" PRIVATE_CURVE_KEY="-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIKn0BKwF3vZvODgWAnUIwQhd8de5oZhY48gc23EWfrfs\n-----END PRIVATE KEY-----" -SIGN_SECRET=group +# SIGN_SECRET="group" + +SIGN_SECRET="secret" # SMTP settings SMTP_API_URL="https://api.smtp.bz/v1/smtp/send" @@ -31,3 +33,7 @@ SMTP_UNAME="kotilion.95@gmail.com" SMTP_PASS="vWwbCSg4bf0p" SMTP_API_KEY="P0YsjUB137upXrr1NiJefHmXVKW1hmBWlpev" SMTP_SENDER="noreply@mailing.pena.digital" + +# URL settings +DEFAULT_REDIRECTION_URL = "def.url" +AUTH_EXCHANGE_URL = "http://localhost:8000/auth/exchange" \ No newline at end of file diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e69de29..dcbd082 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -0,0 +1,87 @@ +openapi: 3.0.0 +info: + title: Codeword Recovery Service API + version: 1.0.0 + description: API for handling password recovery for the Codeword service. + + +paths: + /liveness: + get: + summary: Роут проверки активности + responses: + '200': + description: Успех – сервис запущен + + /readiness: + get: + summary: Роут проверки базы данных + responses: + '200': + description: Успех — сервис готов и соединение с БД живо + '503': + description: Служба недоступна — не удалось выполнить проверку связи с БД + + /recover: + post: + summary: Запустите процесс восстановления пароля + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + email: + type: string + format: email + description: Электронная почта, на которую нужно отправить инструкции по восстановлению + Referrer: + type: string + description: URL-адрес referral, если он доступен + RedirectionURL: + type: string + description: URL-адрес, на который перенаправляется пользователь после отправки электронного письма + + responses: + '200': + description: Запрос на восстановление принят, и возвращен идентификатор записи восстановления + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: Идентификатор запроса на восстановление + '404': + description: Пользователь не найден по электронной почте + '500': + description: Внутренняя ошибка сервера – разные причины + + /recover/{sign}: + get: + summary: Обработать ссылку восстановления, в которой содержится подпись и обменять ее на токены + parameters: + - in: path + name: sign + required: true + schema: + type: string + description: Подпись восстановления как часть URL-адреса восстановления + responses: + '200': + description: Восстановление успешно, информация для обмена токенов возвращена + content: + application/json: + schema: + type: object + properties: + accessToken: + type: string + refreshToken: + type: string + '406': + description: NotAcceptable - срок действия ссылки для восстановления истек или она недействительна + '500': + description: Внутренняя ошибка сервера – разные причины diff --git a/internal/adapters/client/auth.go b/internal/adapters/client/auth.go new file mode 100644 index 0000000..225d282 --- /dev/null +++ b/internal/adapters/client/auth.go @@ -0,0 +1,66 @@ +package client + +import ( + "codeword/internal/models" + "encoding/json" + "fmt" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +type AuthClientDeps struct { + AuthUrl string + FiberClient *fiber.Client + Logger *zap.Logger +} + +type AuthClient struct { + deps AuthClientDeps +} + +func NewAuthClient(deps AuthClientDeps) *AuthClient { + if deps.FiberClient == nil { + deps.FiberClient = fiber.AcquireClient() + } + return &AuthClient{ + deps: deps, + } +} + +func (a *AuthClient) RefreshAuthToken(userID, signature string) (*models.RefreshResponse, error) { + body := models.AuthRequestBody{ + UserID: userID, + Signature: signature, + } + + bodyBytes, err := json.Marshal(body) + if err != nil { + a.deps.Logger.Error("Failed to encode request body", zap.Error(err)) + return nil, err + } + + agent := a.deps.FiberClient.Post(a.deps.AuthUrl) + agent.Set("Content-Type", "application/json").Body(bodyBytes) + + statusCode, resBody, errs := agent.Bytes() + if len(errs) > 0 { + for _, err := range errs { + a.deps.Logger.Error("Error in exchange auth token request", zap.Error(err)) + } + return nil, fmt.Errorf("request failed: %v", errs) + } + + if statusCode != fiber.StatusOK { + errorMessage := fmt.Sprintf("received an incorrect response from the authentication service: %d", statusCode) + a.deps.Logger.Error(errorMessage, zap.Int("status", statusCode)) + return nil, fmt.Errorf(errorMessage) + } + + var tokens models.RefreshResponse + if err := json.Unmarshal(resBody, &tokens); err != nil { + a.deps.Logger.Error("failed to unmarshal auth service response", zap.Error(err)) + return nil, err + } + + return &tokens, nil +} diff --git a/internal/adapters/client/mail.go b/internal/adapters/client/mail.go index 66ae333..1522cf8 100644 --- a/internal/adapters/client/mail.go +++ b/internal/adapters/client/mail.go @@ -9,15 +9,17 @@ import ( ) type RecoveryEmailSenderDeps struct { - SmtpApiUrl string - SmtpHost string - SmtpPort string - SmtpSender string - Username string - Password string - ApiKey string - FiberClient *fiber.Client - Logger *zap.Logger + SmtpApiUrl string + SmtpHost string + SmtpPort string + SmtpSender string + Username string + Password string + ApiKey string + FiberClient *fiber.Client + Logger *zap.Logger + CodewordHost string + CodewordPort string } type RecoveryEmailSender struct { @@ -33,16 +35,12 @@ func NewRecoveryEmailSender(deps RecoveryEmailSenderDeps) *RecoveryEmailSender { } } -func (r *RecoveryEmailSender) SendRecoveryEmail(email string, signature []byte) error { +func (r *RecoveryEmailSender) SendRecoveryEmail(email string, signature string) error { url := r.deps.SmtpApiUrl fmt.Println(email, signature) - message := fmt.Sprintf("To: %s\r\n"+ - "Subject: Восстановление доступа\r\n"+ - "\r\n"+ - "Чтобы восстановить доступ, пожалуйста, перейдите по ссылке ниже:\r\n"+ - " https://hub.pena.digital/codeword/restore/%s\r\n", email, signature) + message := fmt.Sprintf("http://"+r.deps.CodewordHost+":"+r.deps.CodewordPort+"/recover/%s", signature) form := new(bytes.Buffer) writer := multipart.NewWriter(form) @@ -72,16 +70,16 @@ func (r *RecoveryEmailSender) SendRecoveryEmail(email string, signature []byte) statusCode, body, errs := req.Bytes() if errs != nil { - r.deps.Logger.Error("Ошибка при отправке запроса", zap.Error(errs[0])) + r.deps.Logger.Error("Error sending request", zap.Error(errs[0])) return errs[0] } if statusCode != fiber.StatusOK { - err := fmt.Errorf("SMTP сервис вернул ошибку: %s Тело ответа: %s", statusCode, body) - r.deps.Logger.Error("Ошибка при отправке электронной почты", zap.Error(err)) + err := fmt.Errorf("the SMTP service returned an error: %s Response body: %s", statusCode, body) + r.deps.Logger.Error("Error sending email", zap.Error(err)) return err } - r.deps.Logger.Info("Письмо для восстановления отправлено", zap.String("email", email)) + //r.deps.Logger.Info("Recovery email sent", zap.String("email", email)) return nil } diff --git a/internal/app/app.go b/internal/app/app.go index 49cc0cc..112ded4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,69 +1,58 @@ package app import ( - "codeword/internal/adapters/client" controller "codeword/internal/controller/recovery" "codeword/internal/initialize" "codeword/internal/repository" httpserver "codeword/internal/server/http" "codeword/internal/services" - "codeword/internal/utils/encrypt" + "codeword/internal/worker/purge_worker" "codeword/internal/worker/recovery_worker" "context" - "github.com/gofiber/fiber/v2" "go.mongodb.org/mongo-driver/mongo" "go.uber.org/zap" - "time" ) func Run(ctx context.Context, cfg initialize.Config, logger *zap.Logger) error { logger.Info("Запуск приложения", zap.String("AppName", cfg.AppName)) - mdb, err := initialize.InitializeMongoDB(ctx, cfg) + mdb, err := initialize.MongoDB(ctx, cfg) if err != nil { logger.Error("Failed to initialize MongoDB", zap.Error(err)) return err } - rdb, err := initialize.InitializeRedis(ctx, cfg) - - encryptService := encrypt.New(&encrypt.EncryptDeps{ - PublicKey: cfg.PublicCurveKey, - PrivateKey: cfg.PrivateCurveKey, - SignSecret: cfg.SignSecret, - }) - + rdb, err := initialize.Redis(ctx, cfg) + encrypt := initialize.Encrypt(cfg) codewordRepo := repository.NewCodewordRepository(repository.Deps{Rdb: rdb, Mdb: mdb.Collection("codeword")}) userRepo := repository.NewUserRepository(repository.Deps{Rdb: nil, Mdb: mdb.Collection("users")}) - - recoveryEmailSender := client.NewRecoveryEmailSender(client.RecoveryEmailSenderDeps{ - SmtpApiUrl: cfg.SmtpApiUrl, - SmtpHost: cfg.SmtpHost, - SmtpPort: cfg.SmtpPort, - SmtpSender: cfg.SmtpSender, - Username: cfg.SmtpUsername, - Password: cfg.SmtpPassword, - ApiKey: cfg.SmtpApiKey, - FiberClient: &fiber.Client{}, - Logger: logger, - }) + recoveryEmailSender := initialize.RecoveryEmailSender(cfg, logger) + authClient := initialize.AuthClient(cfg, logger) recoveryService := services.NewRecoveryService(services.Deps{ Logger: logger, CodewordRepository: codewordRepo, UserRepository: userRepo, - EncryptService: encryptService, + Encrypt: encrypt, + AuthClient: authClient, }) - recoveryController := controller.NewRecoveryController(logger, recoveryService) + recoveryController := controller.NewRecoveryController(logger, recoveryService, cfg.DefaultRedirectionURL) recoveryWC := recovery_worker.NewRecoveryWC(recovery_worker.Deps{ Logger: logger, Redis: rdb, EmailSender: recoveryEmailSender, + Mongo: mdb.Collection("codeword"), + }) + + purgeWC := purge_worker.NewRecoveryWC(purge_worker.Deps{ + Logger: logger, + Mongo: mdb.Collection("codeword"), }) go recoveryWC.Start(ctx) + go purgeWC.Start(ctx) server := httpserver.NewServer(httpserver.ServerConfig{ Logger: logger, @@ -72,50 +61,44 @@ func Run(ctx context.Context, cfg initialize.Config, logger *zap.Logger) error { go func() { if err := server.Start(cfg.HTTPHost + ":" + cfg.HTTPPort); err != nil { - logger.Error("Ошибка запуска сервера", zap.Error(err)) + logger.Error("Server startup error", zap.Error(err)) } }() <-ctx.Done() - if err := shutdownApp(server, mdb, logger); err != nil { + if err := shutdownApp(ctx, server, mdb, logger); err != nil { return err } - logger.Info("Приложение остановлено") + logger.Info("The application has stopped") return nil } // TODO возможно стоит вынести в отдельные файлы или отказаться от разделения на отдельные методы -func shutdownApp(server *httpserver.Server, mdb *mongo.Database, logger *zap.Logger) error { - if err := shutdownHTTPServer(server, logger); err != nil { +func shutdownApp(ctx context.Context, server *httpserver.Server, mdb *mongo.Database, logger *zap.Logger) error { + if err := shutdownHTTPServer(ctx, server, logger); err != nil { return err } - if err := shutdownMongoDB(mdb, logger); err != nil { + if err := shutdownMongoDB(ctx, mdb, logger); err != nil { return err } return nil } -func shutdownHTTPServer(server *httpserver.Server, logger *zap.Logger) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - +func shutdownHTTPServer(ctx context.Context, server *httpserver.Server, logger *zap.Logger) error { if err := server.Shutdown(ctx); err != nil { - logger.Error("Ошибка при остановке HTTP-сервера", zap.Error(err)) + logger.Error("Error stopping HTTP server", zap.Error(err)) return err } return nil } -func shutdownMongoDB(mdb *mongo.Database, logger *zap.Logger) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - +func shutdownMongoDB(ctx context.Context, mdb *mongo.Database, logger *zap.Logger) error { if err := mdb.Client().Disconnect(ctx); err != nil { - logger.Error("Ошибка при закрытии соединения с MongoDB", zap.Error(err)) + logger.Error("Error when closing MongoDB connection", zap.Error(err)) return err } return nil diff --git a/internal/controller/recovery/recovery_controller.go b/internal/controller/recovery/recovery_controller.go index e92d318..cd10b1d 100644 --- a/internal/controller/recovery/recovery_controller.go +++ b/internal/controller/recovery/recovery_controller.go @@ -1,21 +1,25 @@ package controller import ( + "codeword/internal/models" "codeword/internal/services" + "encoding/base64" "github.com/gofiber/fiber/v2" "go.uber.org/zap" "time" ) type RecoveryController struct { - logger *zap.Logger - service *services.RecoveryService + logger *zap.Logger + service *services.RecoveryService + defaultURL string } -func NewRecoveryController(logger *zap.Logger, service *services.RecoveryService) *RecoveryController { +func NewRecoveryController(logger *zap.Logger, service *services.RecoveryService, defaultRedirectionURL string) *RecoveryController { return &RecoveryController{ - logger: logger, - service: service, + logger: logger, + service: service, + defaultURL: defaultRedirectionURL, } } @@ -26,11 +30,13 @@ func (r *RecoveryController) HandlePingDB(c *fiber.Ctx) error { // HandleRecoveryRequest обрабатывает запрос на восстановление пароля func (r *RecoveryController) HandleRecoveryRequest(c *fiber.Ctx) error { email := c.FormValue("email") + referralURL := c.Get("Referrer") + redirectionURL := c.FormValue("RedirectionURL") - key, err := r.service.GenerateKey() - if err != nil { - r.logger.Error("Failed to generate key", zap.Error(err)) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal Server Error"}) + if redirectionURL == "" && referralURL != "" { + redirectionURL = referralURL + } else if redirectionURL == "" { + redirectionURL = r.defaultURL } user, err := r.service.FindUserByEmail(c.Context(), email) @@ -39,38 +45,52 @@ func (r *RecoveryController) HandleRecoveryRequest(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"}) } - err = r.service.StoreRecoveryRecord(c.Context(), user.ID.Hex(), user.Email, key) + key, err := r.service.GenerateKey() + if err != nil { + r.logger.Error("Failed to generate key", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal Server Error"}) + } + + signUrl := redirectionURL + base64.URLEncoding.EncodeToString(key) + sign := base64.URLEncoding.EncodeToString(key) + + id, err := r.service.StoreRecoveryRecord(c.Context(), models.StoreRecDeps{UserID: user.ID.Hex(), Email: user.Email, Key: sign, Url: signUrl}) if err != nil { r.logger.Error("Failed to store recovery record", zap.Error(err)) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal Server Error"}) } - err = r.service.RecoveryEmailTask(c.Context(), user.ID.Hex(), email, key) + signWithID := sign + id // подпись с id записи + + err = r.service.RecoveryEmailTask(c.Context(), models.RecEmailDeps{UserID: user.ID.Hex(), Email: email, SignWithID: signWithID, ID: id}) if err != nil { r.logger.Error("Failed to send recovery email", zap.Error(err)) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal Server Error"}) } - return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Recovery email sent successfully"}) + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "id": id, + }) } +// todo тут скорее всего помимо подписи будет передаваться еще что-то, например email пользователя от фронта для поиска в бд + // HandleRecoveryLink обрабатывает ссылку восстановления и обменивает ее на токены func (r *RecoveryController) HandleRecoveryLink(c *fiber.Ctx) error { key := c.Params("sign") - // тут получается + record, err := r.service.GetRecoveryRecord(c.Context(), key) if err != nil { r.logger.Error("Failed to get recovery record", zap.Error(err)) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal Server Error"}) } - // проверка на более чем 15 минут if time.Since(record.CreatedAt) > 15*time.Minute { r.logger.Error("Recovery link expired", zap.String("signature", key)) - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Recovery link expired"}) + return c.Status(fiber.StatusNotAcceptable).JSON(fiber.Map{"error": "Recovery link expired"}) } - tokens, err := r.service.ExchangeForTokens(record.UserID) + tokens, err := r.service.ExchangeForTokens(record.UserID, record.Sign) if err != nil { r.logger.Error("Failed to exchange recovery link for tokens", zap.Error(err)) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal Server Error"}) diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 04b3218..df9aae3 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -1 +1,3 @@ package errors + +// пока не нужен diff --git a/internal/initialize/clients.go b/internal/initialize/clients.go new file mode 100644 index 0000000..e7851b8 --- /dev/null +++ b/internal/initialize/clients.go @@ -0,0 +1,31 @@ +package initialize + +import ( + "codeword/internal/adapters/client" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +func RecoveryEmailSender(cfg Config, logger *zap.Logger) *client.RecoveryEmailSender { + return client.NewRecoveryEmailSender(client.RecoveryEmailSenderDeps{ + SmtpApiUrl: cfg.SmtpApiUrl, + SmtpHost: cfg.SmtpHost, + SmtpPort: cfg.SmtpPort, + SmtpSender: cfg.SmtpSender, + Username: cfg.SmtpUsername, + Password: cfg.SmtpPassword, + ApiKey: cfg.SmtpApiKey, + FiberClient: &fiber.Client{}, + Logger: logger, + CodewordHost: cfg.HTTPHost, + CodewordPort: cfg.HTTPPort, + }) +} + +func AuthClient(cfg Config, logger *zap.Logger) *client.AuthClient { + return client.NewAuthClient(client.AuthClientDeps{ + AuthUrl: cfg.AuthURL, + Logger: logger, + FiberClient: &fiber.Client{}, + }) +} diff --git a/internal/initialize/config.go b/internal/initialize/config.go index e85c719..d2205b0 100644 --- a/internal/initialize/config.go +++ b/internal/initialize/config.go @@ -7,28 +7,30 @@ import ( ) type Config struct { - AppName string `env:"APP_NAME" envDefault:"codeword"` - HTTPHost string `env:"HTTP_HOST" envDefault:"localhost"` - HTTPPort string `env:"HTTP_PORT" envDefault:"3000"` - MongoHost string `env:"MONGO_HOST" envDefault:"127.0.0.1"` - MongoPort string `env:"MONGO_PORT" envDefault:"27020"` - MongoUser string `env:"MONGO_USER" envDefault:"test"` - MongoPassword string `env:"MONGO_PASSWORD" envDefault:"test"` - MongoDatabase string `env:"MONGO_DB" envDefault:"admin"` - MongoAuth string `env:"MONGO_AUTH" envDefault:"admin"` - PublicCurveKey string `env:"PUBLIC_CURVE_KEY"` - PrivateCurveKey string `env:"PRIVATE_CURVE_KEY"` - SignSecret string `env:"SIGN_SECRET"` - RedisAddr string `env:"REDIS_ADDR" envDefault:"localhost:6379"` - RedisPassword string `env:"REDIS_PASS" envDefault:"admin"` - RedisDB int `env:"REDIS_DB" envDefault:"2"` - SmtpApiUrl string `env:"SMTP_API_URL"` - SmtpHost string `env:"SMTP_HOST"` - SmtpPort string `env:"SMTP_PORT"` - SmtpUsername string `env:"SMTP_UNAME"` - SmtpPassword string `env:"SMTP_PASS"` - SmtpApiKey string `env:"SMTP_API_KEY"` - SmtpSender string `env:"SMTP_SENDER"` + AppName string `env:"APP_NAME" envDefault:"codeword"` + HTTPHost string `env:"HTTP_HOST" envDefault:"localhost"` + HTTPPort string `env:"HTTP_PORT" envDefault:"3000"` + MongoHost string `env:"MONGO_HOST" envDefault:"127.0.0.1"` + MongoPort string `env:"MONGO_PORT" envDefault:"27020"` + MongoUser string `env:"MONGO_USER" envDefault:"test"` + MongoPassword string `env:"MONGO_PASSWORD" envDefault:"test"` + MongoDatabase string `env:"MONGO_DB" envDefault:"admin"` + MongoAuth string `env:"MONGO_AUTH" envDefault:"admin"` + PublicCurveKey string `env:"PUBLIC_CURVE_KEY"` + PrivateCurveKey string `env:"PRIVATE_CURVE_KEY"` + SignSecret string `env:"SIGN_SECRET"` + RedisAddr string `env:"REDIS_ADDR" envDefault:"localhost:6379"` + RedisPassword string `env:"REDIS_PASS" envDefault:"admin"` + RedisDB int `env:"REDIS_DB" envDefault:"2"` + SmtpApiUrl string `env:"SMTP_API_URL"` + SmtpHost string `env:"SMTP_HOST"` + SmtpPort string `env:"SMTP_PORT"` + SmtpUsername string `env:"SMTP_UNAME"` + SmtpPassword string `env:"SMTP_PASS"` + SmtpApiKey string `env:"SMTP_API_KEY"` + SmtpSender string `env:"SMTP_SENDER"` + DefaultRedirectionURL string `env:"DEFAULT_REDIRECTION_URL"` + AuthURL string `env:"AUTH_EXCHANGE_URL"` } func LoadConfig() (*Config, error) { diff --git a/internal/initialize/encrypt.go b/internal/initialize/encrypt.go new file mode 100644 index 0000000..366147d --- /dev/null +++ b/internal/initialize/encrypt.go @@ -0,0 +1,13 @@ +package initialize + +import ( + "codeword/internal/utils/encrypt" +) + +func Encrypt(cfg Config) *encrypt.Encrypt { + return encrypt.New(&encrypt.EncryptDeps{ + PublicKey: cfg.PublicCurveKey, + PrivateKey: cfg.PrivateCurveKey, + SignSecret: cfg.SignSecret, + }) +} diff --git a/internal/initialize/mongo.go b/internal/initialize/mongo.go index df49819..64fea74 100644 --- a/internal/initialize/mongo.go +++ b/internal/initialize/mongo.go @@ -7,7 +7,7 @@ import ( "time" ) -func InitializeMongoDB(ctx context.Context, cfg Config) (*mongo.Database, error) { +func MongoDB(ctx context.Context, cfg Config) (*mongo.Database, error) { dbConfig := &mdb.Configuration{ MongoHost: cfg.MongoHost, MongoPort: cfg.MongoPort, diff --git a/internal/initialize/redis.go b/internal/initialize/redis.go index bd42631..d8de002 100644 --- a/internal/initialize/redis.go +++ b/internal/initialize/redis.go @@ -5,7 +5,7 @@ import ( "github.com/go-redis/redis/v8" ) -func InitializeRedis(ctx context.Context, cfg Config) (*redis.Client, error) { +func Redis(ctx context.Context, cfg Config) (*redis.Client, error) { rdb := redis.NewClient(&redis.Options{ Addr: cfg.RedisAddr, Password: cfg.RedisPassword, diff --git a/internal/models/auth.go b/internal/models/auth.go new file mode 100644 index 0000000..a159a74 --- /dev/null +++ b/internal/models/auth.go @@ -0,0 +1,11 @@ +package models + +type AuthRequestBody struct { + UserID string `json:"userId"` + Signature string `json:"signature"` +} + +type RefreshResponse struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` +} diff --git a/internal/models/deps.go b/internal/models/deps.go new file mode 100644 index 0000000..824660d --- /dev/null +++ b/internal/models/deps.go @@ -0,0 +1,15 @@ +package models + +type StoreRecDeps struct { + UserID string + Email string + Key string + Url string +} + +type RecEmailDeps struct { + UserID string + Email string + SignWithID string + ID string +} diff --git a/internal/models/user.go b/internal/models/user.go index 544e227..dfe0a2f 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -18,18 +18,20 @@ type User struct { } type RestoreRequest struct { - ID string // xid или ObjectID - CreatedAt time.Time - Sign string // подпись - Email string // email из запроса - UserID string // айдишник юзера, которого нашли по email - Sent bool - SentAt time.Time + ID primitive.ObjectID `bson:"_id,omitempty"` + CreatedAt time.Time `bson:"created_at,omitempty"` + Sign string `bson:"sign,omitempty"` + SignUrl string `bson:"sign_url,omitempty"` + SignID string `bson:"sign_id"` + Email string `bson:"email,omitempty"` + UserID string `bson:"user_id,omitempty"` + Sent bool `bson:"sent"` + SentAt time.Time `bson:"sent_at"` } type RecoveryRecord struct { - UserID string `bson:"user_id"` - Email string `bson:"email"` - Key []byte `bson:"key"` - CreatedAt time.Time `bson:"created_at"` + ID string + UserID string + Email string + Key string } diff --git a/internal/repository/codeword_repository.go b/internal/repository/codeword_repository.go new file mode 100644 index 0000000..75d38e1 --- /dev/null +++ b/internal/repository/codeword_repository.go @@ -0,0 +1,88 @@ +package repository + +import ( + "codeword/internal/models" + "context" + "encoding/json" + "github.com/go-redis/redis/v8" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/readpref" + "time" +) + +type codewordRepository struct { + mdb *mongo.Collection + rdb *redis.Client +} + +func NewCodewordRepository(deps Deps) *codewordRepository { + + return &codewordRepository{mdb: deps.Mdb, rdb: deps.Rdb} +} + +// сохраняем полученные данные о пользователе и подписи в бд +func (r *codewordRepository) StoreRecoveryRecord(ctx context.Context, deps models.StoreRecDeps) (string, error) { + newID := primitive.NewObjectID() + signID := deps.Key + newID.Hex() + record := models.RestoreRequest{ + ID: newID, + UserID: deps.UserID, + Email: deps.Email, + Sign: deps.Key, + SignUrl: deps.Url, + SignID: signID, + CreatedAt: time.Now(), + } + + _, err := r.mdb.InsertOne(ctx, record) + if err != nil { + return "", err + } + + return newID.Hex(), nil +} + +// добавляем в очередь данные для отправки на почту в редис +func (r *codewordRepository) InsertToQueue(ctx context.Context, deps models.RecEmailDeps) error { + task := models.RecoveryRecord{ + ID: deps.ID, + UserID: deps.UserID, + Email: deps.Email, + Key: deps.SignWithID, + } + + taskBytes, err := json.Marshal(task) + if err != nil { + return err + } + + if err := r.rdb.LPush(ctx, "recoveryQueue", taskBytes).Err(); err != nil { + return err + } + + return nil +} + +// получаем данные юзера по подписи +func (r *codewordRepository) GetRecoveryRecord(ctx context.Context, key string) (*models.RestoreRequest, error) { + var restoreRequest models.RestoreRequest + + filter := bson.M{"sign_id": key} + + err := r.mdb.FindOne(ctx, filter).Decode(&restoreRequest) + if err != nil { + return nil, err + } + + return &restoreRequest, nil +} + +// пингует в монгу чтобы проверить подключение +func (r *codewordRepository) Ping(ctx context.Context) error { + if err := r.mdb.Database().Client().Ping(ctx, readpref.Primary()); err != nil { + return err + } + return nil +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index 7c9a146..3b2e2c7 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -3,13 +3,9 @@ package repository import ( "codeword/internal/models" "context" - "encoding/json" "github.com/go-redis/redis/v8" - "github.com/pioz/faker" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/readpref" - "time" ) type Deps struct { @@ -17,11 +13,6 @@ type Deps struct { Rdb *redis.Client } -type codewordRepository struct { - mdb *mongo.Collection - rdb *redis.Client -} - type userRepository struct { mdb *mongo.Collection } @@ -31,11 +22,7 @@ func NewUserRepository(deps Deps) *userRepository { return &userRepository{mdb: deps.Mdb} } -func NewCodewordRepository(deps Deps) *codewordRepository { - - return &codewordRepository{mdb: deps.Mdb, rdb: deps.Rdb} -} - +// ищем пользователя по мейлу в коллекции users func (r *userRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) { var user models.User @@ -48,51 +35,3 @@ func (r *userRepository) FindByEmail(ctx context.Context, email string) (*models } return &user, nil } - -func (r *codewordRepository) StoreRecoveryRecord(ctx context.Context, userID string, email string, key []byte) error { - record := models.RecoveryRecord{ - UserID: userID, - Email: email, - Key: key, - CreatedAt: time.Now(), - } - - _, err := r.mdb.InsertOne(ctx, record) - if err != nil { - return err - } - - return nil -} - -func (r *codewordRepository) InsertToQueue(ctx context.Context, userID string, email string, key []byte) error { - // todo не забыть убрать потом этот цикл - for i := 0; i < 10; i++ { - - task := models.RecoveryRecord{ - UserID: userID + faker.String(), - Email: email, - Key: key, - CreatedAt: time.Now(), - } - - taskBytes, err := json.Marshal(task) - if err != nil { - return err - } - - if err := r.rdb.LPush(ctx, "recoveryQueue", taskBytes).Err(); err != nil { - return err - } - } - - return nil -} - -func (r *codewordRepository) GetRecoveryRecord(ctx context.Context, key string) (*models.RestoreRequest, error) { - return &models.RestoreRequest{UserID: "123", Sign: key, CreatedAt: time.Now()}, nil -} - -func (r *codewordRepository) Ping(ctx context.Context) error { - return r.mdb.Database().Client().Ping(ctx, readpref.Primary()) -} diff --git a/internal/server/http/http_server.go b/internal/server/http/http_server.go index 4b0808a..ca397ff 100644 --- a/internal/server/http/http_server.go +++ b/internal/server/http/http_server.go @@ -35,7 +35,11 @@ func NewServer(config ServerConfig) *Server { } func (s *Server) Start(addr string) error { - return s.app.Listen(addr) + if err := s.app.Listen(addr); err != nil { + s.Logger.Error("Failed to start server", zap.Error(err)) + return err + } + return nil } func (s *Server) Shutdown(ctx context.Context) error { diff --git a/internal/services/recovery_service.go b/internal/services/recovery_service.go index 0d5ea87..664d447 100644 --- a/internal/services/recovery_service.go +++ b/internal/services/recovery_service.go @@ -1,15 +1,17 @@ package services import ( + "codeword/internal/adapters/client" "codeword/internal/models" "codeword/internal/utils/encrypt" "context" + "encoding/base64" "go.uber.org/zap" ) type CodewordRepository interface { - StoreRecoveryRecord(ctx context.Context, userID string, email string, key []byte) error - InsertToQueue(ctx context.Context, userID string, email string, key []byte) error + StoreRecoveryRecord(ctx context.Context, deps models.StoreRecDeps) (string, error) + InsertToQueue(ctx context.Context, deps models.RecEmailDeps) error Ping(ctx context.Context) error GetRecoveryRecord(ctx context.Context, key string) (*models.RestoreRequest, error) } @@ -22,14 +24,16 @@ type Deps struct { Logger *zap.Logger CodewordRepository CodewordRepository UserRepository UserRepository - EncryptService *encrypt.Encrypt + Encrypt *encrypt.Encrypt + AuthClient *client.AuthClient } type RecoveryService struct { logger *zap.Logger repositoryCodeword CodewordRepository repositoryUser UserRepository - encryptService *encrypt.Encrypt + encrypt *encrypt.Encrypt + authClient *client.AuthClient } func NewRecoveryService(deps Deps) *RecoveryService { @@ -37,45 +41,99 @@ func NewRecoveryService(deps Deps) *RecoveryService { logger: deps.Logger, repositoryCodeword: deps.CodewordRepository, repositoryUser: deps.UserRepository, - encryptService: deps.EncryptService, + encrypt: deps.Encrypt, + authClient: deps.AuthClient, } } // GenerateKey генерирует ключ, используя шифрование на основе эллиптической кривой func (s *RecoveryService) GenerateKey() ([]byte, error) { - key, err := s.encryptService.SignCommonSecret() + key, err := s.encrypt.SignCommonSecret() if err != nil { + s.logger.Error("Failed to generate unique key for user", zap.Error(err)) return nil, err } return key, nil } +// вызывает пингование в бд func (s *RecoveryService) Ping(ctx context.Context) error { - return s.repositoryCodeword.Ping(ctx) + err := s.repositoryCodeword.Ping(ctx) + if err != nil { + s.logger.Error("Failed to ping database", zap.Error(err)) + return err + } + return nil } // FindUserByEmail ищет пользователя по электронной почте func (s *RecoveryService) FindUserByEmail(ctx context.Context, email string) (*models.User, error) { - return s.repositoryUser.FindByEmail(ctx, email) + user, err := s.repositoryUser.FindByEmail(ctx, email) + if err != nil { + s.logger.Error("Failed to find user by email", zap.String("email", email), zap.Error(err)) + return nil, err + } + if user == nil { + s.logger.Info("No user found with email", zap.String("email", email)) + return nil, nil + } + return user, nil } // StoreRecoveryRecord сохраняет запись восстановления в базе данных -func (s *RecoveryService) StoreRecoveryRecord(ctx context.Context, userID string, email string, key []byte) error { - return s.repositoryCodeword.StoreRecoveryRecord(ctx, userID, email, key) +func (s *RecoveryService) StoreRecoveryRecord(ctx context.Context, deps models.StoreRecDeps) (string, error) { + id, err := s.repositoryCodeword.StoreRecoveryRecord(ctx, models.StoreRecDeps{UserID: deps.UserID, Email: deps.Email, Key: deps.Key, Url: deps.Url}) + if err != nil { + s.logger.Error("Failed save data in mongoDB for email", zap.String("email", deps.Email), zap.Error(err)) + return "", err + } + return id, nil } -// SendRecoveryEmail посылает письмо для восстановления доступа пользователю -func (s *RecoveryService) RecoveryEmailTask(ctx context.Context, userID string, email string, key []byte) error { - return s.repositoryCodeword.InsertToQueue(ctx, userID, email, key) +// RecoveryEmailTask посылает письмо для восстановления доступа пользователю +func (s *RecoveryService) RecoveryEmailTask(ctx context.Context, deps models.RecEmailDeps) error { + err := s.repositoryCodeword.InsertToQueue(ctx, models.RecEmailDeps{UserID: deps.UserID, Email: deps.Email, SignWithID: deps.SignWithID, ID: deps.ID}) + if err != nil { + s.logger.Error("Failed creating a task to send a worker by email", zap.String("email", deps.Email), zap.Error(err)) + return err + } + return nil } // GetRecoveryRecord получает запись восстановления из базы данных func (s *RecoveryService) GetRecoveryRecord(ctx context.Context, key string) (*models.RestoreRequest, error) { - return s.repositoryCodeword.GetRecoveryRecord(ctx, key) + req, err := s.repositoryCodeword.GetRecoveryRecord(ctx, key) + if err != nil { + s.logger.Error("Failed to obtain signature recovery data", zap.String("signature", key), zap.Error(err)) + return nil, err + } + + byteKey, err := base64.URLEncoding.DecodeString(req.Sign) + if err != nil { + s.logger.Error("Failed to decode string signature to []byte format", zap.String("signature", key), zap.Error(err)) + return nil, err + } + + // сомнительный вариант но как я думаю верный, что false==err + result, err := s.encrypt.VerifySignature(byteKey) + if err != nil || !result { + s.logger.Error("Failed to verify signature", zap.String("signature", key), zap.Error(err)) + return nil, err + } + + return req, nil } -// ExchangeForTokens обменивает ссылку восстановления на токены используя сервис аутентификации. -func (s *RecoveryService) ExchangeForTokens(userID string) (map[string]string, error) { - // TODO - return nil, nil +// меняет подпись на токены идя в auth сервис +func (s *RecoveryService) ExchangeForTokens(userID string, signature string) (map[string]string, error) { + tokens, err := s.authClient.RefreshAuthToken(userID, signature) + if err != nil { + s.logger.Error("Failed to refresh auth token", zap.Error(err)) + return nil, err + } + + return map[string]string{ + "accessToken": tokens.AccessToken, + "refreshToken": tokens.RefreshToken, + }, nil } diff --git a/internal/utils/encrypt/encrypt_util.go b/internal/utils/encrypt/encrypt_util.go index 36695e3..9f2d4ac 100644 --- a/internal/utils/encrypt/encrypt_util.go +++ b/internal/utils/encrypt/encrypt_util.go @@ -4,6 +4,7 @@ import ( "crypto/ed25519" "crypto/x509" "encoding/pem" + "errors" "fmt" ) @@ -46,12 +47,13 @@ func (receiver *Encrypt) VerifySignature(signature []byte) (isValid bool, err er publicKey, ok := rawPublicKey.(ed25519.PublicKey) if !ok { - return false, fmt.Errorf("failed convert to ed25519.PrivateKey on of : %w", err) + return false, errors.New("public key is not of type ed25519.PublicKey") } return ed25519.Verify(publicKey, []byte(receiver.signSecret), signature), nil } +// TODO подумать над тем чтобы подпись генерилась каждый раз разгая func (receiver *Encrypt) SignCommonSecret() (signature []byte, err error) { defer func() { if recovered := recover(); recovered != nil { diff --git a/internal/worker/purge_worker/purge_worker.go b/internal/worker/purge_worker/purge_worker.go new file mode 100644 index 0000000..e621641 --- /dev/null +++ b/internal/worker/purge_worker/purge_worker.go @@ -0,0 +1,56 @@ +package purge_worker + +import ( + "context" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.uber.org/zap" + "time" +) + +type Deps struct { + Logger *zap.Logger + Mongo *mongo.Collection +} + +type PurgeWorker struct { + logger *zap.Logger + mongo *mongo.Collection +} + +func NewRecoveryWC(deps Deps) *PurgeWorker { + return &PurgeWorker{ + logger: deps.Logger, + mongo: deps.Mongo, + } +} + +func (wc *PurgeWorker) Start(ctx context.Context) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + wc.processTasks(ctx) + + case <-ctx.Done(): + return + } + } +} + +func (wc *PurgeWorker) processTasks(ctx context.Context) { + wc.logger.Info("Checking cleaning records") + + oneHourAgo := time.Now().Add(-1 * time.Hour) + + filter := bson.M{"created_at": bson.M{"$lt": oneHourAgo}} + + result, err := wc.mongo.DeleteMany(ctx, filter) + if err != nil { + wc.logger.Error("Error when trying to delete old entries", zap.Error(err)) + } else { + wc.logger.Info("Deleted documents", zap.Int64("count", result.DeletedCount)) + } +} diff --git a/internal/worker/recovery_worker/recovery_worker.go b/internal/worker/recovery_worker/recovery_worker.go index 93f495a..7237278 100644 --- a/internal/worker/recovery_worker/recovery_worker.go +++ b/internal/worker/recovery_worker/recovery_worker.go @@ -6,6 +6,9 @@ import ( "context" "encoding/json" "github.com/go-redis/redis/v8" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" "go.uber.org/zap" "time" ) @@ -14,23 +17,26 @@ type Deps struct { Logger *zap.Logger Redis *redis.Client EmailSender *client.RecoveryEmailSender + Mongo *mongo.Collection } -type recoveryWorker struct { +type RecoveryWorker struct { logger *zap.Logger redis *redis.Client emailSender *client.RecoveryEmailSender + mongo *mongo.Collection } -func NewRecoveryWC(deps Deps) *recoveryWorker { - return &recoveryWorker{ +func NewRecoveryWC(deps Deps) *RecoveryWorker { + return &RecoveryWorker{ logger: deps.Logger, redis: deps.Redis, emailSender: deps.EmailSender, + mongo: deps.Mongo, } } -func (wc *recoveryWorker) Start(ctx context.Context) { +func (wc *RecoveryWorker) Start(ctx context.Context) { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() @@ -45,7 +51,7 @@ func (wc *recoveryWorker) Start(ctx context.Context) { } } -func (wc *recoveryWorker) processTasks(ctx context.Context) { +func (wc *RecoveryWorker) processTasks(ctx context.Context) { result, err := wc.redis.BRPop(ctx, 1*time.Second, "recoveryQueue").Result() if err != nil { if err != redis.Nil { @@ -65,19 +71,46 @@ func (wc *recoveryWorker) processTasks(ctx context.Context) { return } - err = wc.sendRecoveryTask(task) + err = wc.sendRecoveryTask(ctx, task) if err != nil { wc.logger.Error("Failed to send recovery task", zap.String("key", result[0]), zap.Error(err)) return } } -func (wc *recoveryWorker) sendRecoveryTask(task models.RecoveryRecord) error { +func (wc *RecoveryWorker) sendRecoveryTask(ctx context.Context, task models.RecoveryRecord) error { err := wc.emailSender.SendRecoveryEmail(task.Email, task.Key) if err != nil { wc.logger.Error("Failed to send recovery email", zap.Error(err)) return err } - wc.logger.Info("Recovery email sent successfully", zap.String("email", task.Email)) + + update := bson.M{ + "$set": bson.M{ + "sent": true, + "sent_at": time.Now(), + }, + } + + objectID, err := primitive.ObjectIDFromHex(task.ID) + if err != nil { + wc.logger.Error("Invalid ObjectID", zap.String("ID", task.ID), zap.Error(err)) + return err + } + + filter := bson.M{"_id": objectID} + + result, err := wc.mongo.UpdateOne(ctx, filter, update) + if err != nil { + wc.logger.Error("Failed to update restore request", zap.Error(err)) + return err + } + + if result.ModifiedCount == 0 { + wc.logger.Warn("No documents were updated - this may indicate the document was not found", + zap.String("ID", task.ID)) + } + + //wc.logger.Info("Recovery email sent and restore request updated successfully", zap.String("email", task.Email)) return nil } diff --git a/tests/repository_test/repository_test.go b/tests/repository_test/repository_test.go index 566dc99..6b69033 100644 --- a/tests/repository_test/repository_test.go +++ b/tests/repository_test/repository_test.go @@ -4,6 +4,7 @@ import ( "codeword/internal/models" "codeword/internal/repository" "context" + "fmt" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" @@ -16,6 +17,8 @@ import ( "github.com/stretchr/testify/assert" ) +// todo add another tests + const mongoURI = "mongodb://test:test@127.0.0.1:27020/?authMechanism=SCRAM-SHA-256&authSource=admin&directConnection=true" func TestFindByEmail(t *testing.T) { @@ -75,16 +78,17 @@ func TestStoreRecoveryRecord(t *testing.T) { for i := 0; i < 10; i++ { userID := faker.String() email := faker.Email() - key := []byte("test_recovery_key") + key := "test_recovery_key" - err = userRepo.StoreRecoveryRecord(ctx, userID, email, key) + id, err := userRepo.StoreRecoveryRecord(ctx, models.StoreRecDeps{UserID: userID, Email: email, Key: key, Url: "def.url"}) assert.NoError(t, err) + fmt.Println(id) - var storedRecord models.RecoveryRecord + var storedRecord models.RestoreRequest err = codeword.FindOne(ctx, bson.M{"user_id": userID}).Decode(&storedRecord) assert.NoError(t, err) assert.Equal(t, email, storedRecord.Email) - assert.Equal(t, string(key), storedRecord.Key) + assert.Equal(t, key, storedRecord.Sign) } _ = database.Drop(ctx)