Merge branch '123' into 'main'

123

See merge request pena-services/codeword!5
This commit is contained in:
Mikhail 2024-01-17 23:49:44 +00:00
commit 37888d528a
23 changed files with 630 additions and 210 deletions

12
.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"

@ -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: Внутренняя ошибка сервера разные причины

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

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

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

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

@ -1 +1,3 @@
package errors
// пока не нужен

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

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

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

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

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

11
internal/models/auth.go Normal file

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

15
internal/models/deps.go Normal file

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

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

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

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

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

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

@ -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 <VerifySignature> of <EncryptService>: %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 {

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

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

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