From f7096afaa575d26642cfa1d8b42172df14c76b45 Mon Sep 17 00:00:00 2001 From: Sergey Chubaryan Date: Mon, 17 Feb 2025 09:45:46 +0300 Subject: [PATCH] fix email verifying, add fake smtp server --- cmd/backend/app.go | 1 + cmd/backend/config.yaml | 4 +- .../server/handlers/user_create_handler.go | 5 +- .../server/handlers/user_verify_handler.go | 15 ++++ cmd/backend/server/server.go | 1 + cmd/coworker/config.yaml | 16 +++-- cmd/coworker/main.go | 33 ++++++--- docker-compose.yaml | 29 ++++++-- internal/core/services/user_service.go | 15 +++- internal/http_server/request_log.go | 2 +- tests/api.py | 40 +++++------ tests/integration/test_user.py | 72 ++++++++++++------- tests/performance/dummy.py | 15 ---- tests/performance/health.py | 11 --- 14 files changed, 157 insertions(+), 102 deletions(-) delete mode 100644 tests/performance/dummy.py delete mode 100644 tests/performance/health.py diff --git a/cmd/backend/app.go b/cmd/backend/app.go index 59c1b26..8a9641f 100644 --- a/cmd/backend/app.go +++ b/cmd/backend/app.go @@ -155,6 +155,7 @@ func (a *App) Run(p RunParams) { JwtCache: jwtCache, EventRepo: *eventRepo, ActionTokenRepo: actionTokenRepo, + Logger: logger, }, ) shortlinkService = services.NewShortlinkSevice( diff --git a/cmd/backend/config.yaml b/cmd/backend/config.yaml index d5df5f4..e50060a 100644 --- a/cmd/backend/config.yaml +++ b/cmd/backend/config.yaml @@ -1,5 +1,5 @@ port: 8080 postgres_url: "postgres://postgres:postgres@localhost:5432/postgres" jwt_signing_key: "./jwt_signing_key" -kafka_url: "localhost:9092" -kafka_topic: "backend_events" \ No newline at end of file +kafka_url: "localhost:9091" +kafka_topic: "events" \ No newline at end of file diff --git a/cmd/backend/server/handlers/user_create_handler.go b/cmd/backend/server/handlers/user_create_handler.go index 392bbdb..35eece3 100644 --- a/cmd/backend/server/handlers/user_create_handler.go +++ b/cmd/backend/server/handlers/user_create_handler.go @@ -31,10 +31,9 @@ func NewUserCreateHandler(log logger.Logger, userService services.UserService) g Name: input.Name, }, ) - - out := createUserOutput{} + if err != nil { - return out, err + return createUserOutput{}, err } return createUserOutput{ diff --git a/cmd/backend/server/handlers/user_verify_handler.go b/cmd/backend/server/handlers/user_verify_handler.go index b6a6163..baff1c4 100644 --- a/cmd/backend/server/handlers/user_verify_handler.go +++ b/cmd/backend/server/handlers/user_verify_handler.go @@ -2,7 +2,9 @@ package handlers import ( "backend/internal/core/services" + httpserver "backend/internal/http_server" "backend/pkg/logger" + "context" "html/template" @@ -69,3 +71,16 @@ func NewUserVerifyEmailHandler(log logger.Logger, userService services.UserServi c.Status(200) } } + +type inputSendVerify struct { + Email string `json:"email" validate:"required,email"` +} + +func NewUserSendVerifyEmailHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc { + return httpserver.WrapGin(log, + func(ctx context.Context, input inputSendVerify) (interface{}, error) { + err := userService.SendEmailVerifyEmail(ctx, input.Email) + return nil, err + }, + ) +} diff --git a/cmd/backend/server/server.go b/cmd/backend/server/server.go index 68e0ec0..b4c6ee8 100644 --- a/cmd/backend/server/server.go +++ b/cmd/backend/server/server.go @@ -48,6 +48,7 @@ func NewServer(opts NewServerOpts) *httpserver.Server { { userGroup.POST("/create", handlers.NewUserCreateHandler(opts.Logger, opts.UserService)) userGroup.POST("/login", handlers.NewUserLoginHandler(opts.Logger, opts.UserService)) + userGroup.POST("/send-verify", handlers.NewUserSendVerifyEmailHandler(opts.Logger, opts.UserService)) } diff --git a/cmd/coworker/config.yaml b/cmd/coworker/config.yaml index 4bb1b89..154c9b1 100644 --- a/cmd/coworker/config.yaml +++ b/cmd/coworker/config.yaml @@ -1,11 +1,13 @@ app: - serviceUrl: "https://localhost:8080" + serviceUrl: "http://localhost:8080" kafka: brokers: - - localhost:9092 - topic: backend_events + - localhost:9091 + topic: events + consumerGroupId: AAAA smtp: - server: smtp.yandex.ru - port: 587 - email: "" - password: "" \ No newline at end of file + server: localhost + port: 12333 + login: "maillogin" + password: "mailpass" + email: "coworker@example.com" \ No newline at end of file diff --git a/cmd/coworker/main.go b/cmd/coworker/main.go index a9ca104..bf06f13 100644 --- a/cmd/coworker/main.go +++ b/cmd/coworker/main.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "html/template" "io" "log" "os" @@ -21,19 +22,21 @@ const MSG_TEXT = `

This message was sent because you forgot a password

-

To change a password, use this link

+

To change a password, use thislink

` -func SendEmailForgotPassword(dialer *gomail.Dialer, from, to, link string) error { - msgText := strings.ReplaceAll(MSG_TEXT, "{{Link}}", link) +type HtmlTemplate struct { + Link string +} +func SendEmailForgotPassword(dialer *gomail.Dialer, from, to, body string) error { m := gomail.NewMessage() m.SetHeader("From", m.FormatAddress(from, "Pet Backend")) m.SetHeader("To", to) m.SetHeader("Subject", "Hello!") - m.SetBody("text/html", msgText) + m.SetBody("text/html", body) return dialer.DialAndSend(m) } @@ -53,8 +56,9 @@ type Config struct { SMTP struct { Server string `yaml:"server"` Port int `yaml:"port"` - Email string `yaml:"email"` + Login string `yaml:"login"` Password string `yaml:"password"` + Email string `yaml:"email"` } `yaml:"smtp"` } @@ -71,7 +75,7 @@ func main() { log.Fatal(err.Error()) } - dialer := gomail.NewDialer(config.SMTP.Server, config.SMTP.Port, config.SMTP.Email, config.SMTP.Password) + dialer := gomail.NewDialer(config.SMTP.Server, config.SMTP.Port, config.SMTP.Login, config.SMTP.Password) r := kafka.NewReader(kafka.ReaderConfig{ Brokers: config.Kafka.Brokers, @@ -92,6 +96,11 @@ func main() { logger.Printf("coworker service started\n") + template, err := template.New("verify-email").Parse(MSG_TEXT) + if err != nil { + log.Fatal(err) + } + for { msg, err := r.FetchMessage(ctx) if err == io.EOF { @@ -120,10 +129,16 @@ func main() { continue } - link := fmt.Sprintf("%s/restore-password?token=%s", config.App.ServiceUrl, value.Token) + link := fmt.Sprintf("%s/verify-user?token=%s", config.App.ServiceUrl, value.Token) - if err := SendEmailForgotPassword(dialer, config.SMTP.Email, value.Email, link); err != nil { - log.Fatalf("failed to send email: %s\n", err.Error()) + builder := &strings.Builder{} + if err := template.Execute(builder, HtmlTemplate{link}); err != nil { + log.Printf("failed to execute html template: %s\n", err.Error()) + continue + } + + if err := SendEmailForgotPassword(dialer, config.SMTP.Email, value.Email, builder.String()); err != nil { + log.Printf("failed to send email: %s\n", err.Error()) continue } } diff --git a/docker-compose.yaml b/docker-compose.yaml index 0262ab0..87d56c3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -104,25 +104,26 @@ services: kafka: image: &kafkaImage apache/kafka:3.8.0 healthcheck: - test: ["CMD-SHELL", "/opt/kafka/bin/kafka-cluster.sh cluster-id --bootstrap-server http://127.0.0.1:9092 || exit 1"] + test: ["CMD-SHELL", "/opt/kafka/bin/kafka-cluster.sh cluster-id --bootstrap-server http://kafka:9092 || exit 1"] interval: 1s timeout: 30s retries: 30 environment: KAFKA_NODE_ID: 1 KAFKA_PROCESS_ROLES: broker,controller - KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 - KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 + KAFKA_INTER_BROKER_LISTENER_NAME: BROKER KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT - KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093 + KAFKA_LISTENERS: BACKEND://0.0.0.0:9091, BROKER://kafka:9092, CONTROLLER://kafka:9093 + KAFKA_ADVERTISED_LISTENERS: BACKEND://localhost:9091, BROKER://kafka:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: BACKEND:PLAINTEXT, BROKER:PLAINTEXT, CONTROLLER:PLAINTEXT + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 KAFKA_NUM_PARTITIONS: 3 ports: - - 9092:9092 + - 9091:9091 kafka-init: image: *kafkaImage @@ -162,6 +163,22 @@ services: exit 0; " + smtp4dev: + image: rnwood/smtp4dev:v3 + restart: always + ports: + - '12332:80' #WebUI + - '12333:25' #SMTP + - '12334:143' #IMAP + # volumes: + # - smtp4dev-data:/smtp4dev + environment: + - ServerOptions__Urls=http://*:80 + - ServerOptions__HostName=localhost + - ServerOptions__TlsMode=None + - RelayOptions__Login=maillogin + - RelayOptions__Password=mailpass + volumes: postgres-volume: grafana-volume: diff --git a/internal/core/services/user_service.go b/internal/core/services/user_service.go index 64cb446..308db2a 100644 --- a/internal/core/services/user_service.go +++ b/internal/core/services/user_service.go @@ -5,6 +5,7 @@ import ( "backend/internal/core/repos" "backend/internal/core/utils" "backend/pkg/cache" + "backend/pkg/logger" "context" "fmt" "time" @@ -33,7 +34,7 @@ type UserService interface { VerifyEmail(ctx context.Context, actionToken string) error SendEmailForgotPassword(ctx context.Context, userId string) error - SendEmailVerifyEmail(ctx context.Context, userId string) error + SendEmailVerifyEmail(ctx context.Context, email string) error ChangePassword(ctx context.Context, userId, oldPassword, newPassword string) error ChangePasswordWithToken(ctx context.Context, userId, actionToken, newPassword string) error @@ -51,6 +52,7 @@ type UserServiceDeps struct { JwtCache cache.Cache[string, string] EventRepo repos.EventRepo ActionTokenRepo repos.ActionTokenRepo + Logger logger.Logger } type userService struct { @@ -91,7 +93,10 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) ( if err != nil { return nil, err } - u.sendEmailVerifyEmail(ctx, result.Id, user.Email) + + if err := u.sendEmailVerifyEmail(ctx, result.Id, user.Email); err != nil { + u.deps.Logger.Error().Err(err).Msg("error occured on sending email") + } u.deps.UserCache.Set(result.Id, *result, cache.Expiration{Ttl: userCacheTtl}) @@ -190,6 +195,12 @@ func (u *userService) SendEmailVerifyEmail(ctx context.Context, email string) er if err != nil { return err } + if user == nil { + return fmt.Errorf("no such user") + } + if user.EmailVerified { + return fmt.Errorf("user already verified") + } return u.sendEmailVerifyEmail(ctx, user.Id, user.Email) } diff --git a/internal/http_server/request_log.go b/internal/http_server/request_log.go index 45123ca..0a32ec3 100644 --- a/internal/http_server/request_log.go +++ b/internal/http_server/request_log.go @@ -44,7 +44,7 @@ func NewRequestLogMiddleware(logger log.Logger, tracer trace.Tracer, prometheus msg := fmt.Sprintf("Request %s %s %d %v", method, path, statusCode, latency) if statusCode >= 200 && statusCode < 400 { - // ctxLogger.Log().Msg(msg) + ctxLogger.Log().Msg(msg) return } diff --git a/tests/api.py b/tests/api.py index b0e132f..58ee186 100644 --- a/tests/api.py +++ b/tests/api.py @@ -11,7 +11,6 @@ class Requests(): class Auth(): token: string - def __init__(self, token): self.token = token @@ -21,21 +20,30 @@ class User(): name: string password: string - def __init__(self, email, password, name, id="", token = ""): + def __init__(self, email, password, name, id=""): + self.id = id self.email = email self.password = password self.name = name - self.token = token + @classmethod + def random(cls): + email = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) + '@example.com' + name = ''.join(random.choices(string.ascii_letters, k=10)) + password = 'Abcdef1!!1' + return cls(email, password, name) + +def rand_email(): + return ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) + '@example.com' class BackendApi(): def __init__(self, httpClient): self.httpClient = httpClient def parse_response(self, response): - if response.status != 200: + if response.status_code != 200: raise AssertionError('Request error') - + json = response.json() if json['status'] == 'success': if 'result' in json: @@ -45,35 +53,27 @@ class BackendApi(): error = json['error'] raise AssertionError(error['id'], error['message']) - def user_create(self, user: User | None) -> User: - if user == None: - email = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) + '@test.test' - name = ''.join(random.choices(string.ascii_letters, k=10)) - password = 'Abcdef1!!1' - user = User(email, password, name) - + def user_create(self, user: User) -> User: res = self.parse_response( self.httpClient.post( - "/v1/user/create", json={ + "/api/v1/user/create", json={ "email": user.email, "password": user.password, "name": user.name, } ) ) - - return User(res['email'], res['password'], res['name'], res['id']) + return User(res['email'], user.password, res['name'], id=res['id']) - def user_login(self, user: User) -> Auth: + def user_login(self, email, password) -> Auth: res = self.parse_response( self.httpClient.post( - "/v1/user/login", json={ - "email": user.email+"a", - "password": user.password, + "/api/v1/user/login", json={ + "email": email, + "password": password, }, ) ) - return Auth(res['status']) def dummy_get(self, auth: Auth): diff --git a/tests/integration/test_user.py b/tests/integration/test_user.py index b63c935..fab6381 100644 --- a/tests/integration/test_user.py +++ b/tests/integration/test_user.py @@ -1,33 +1,53 @@ +import random +import string import pytest -from api import BackendApi, Requests, User +from kafka import KafkaConsumer +from api import BackendApi, Requests, User, rand_email backendUrl = "http://localhost:8080" -class TestUser: - def test_create_user(self): - api = BackendApi(Requests(backendUrl)) +def test_create_user(): + backend = BackendApi(Requests(backendUrl)) - user = User("user@example.com", "aaaaaA1!", "SomeName") - userWithBadEmail = User("example.com", "aaaaaA1!", "SomeName") - userWithBadPassword = User("user@example.com", "badPassword", "SomeName") - userWithBadName = User("user@example.com", "aaaaaA1!", "") + user = User.random() + userWithBadEmail = User("sdfsaadsfgdf", user.password, user.name) + userWithBadPassword = User(user.email, "badPassword", user.name) - with pytest.raises(Exception) as e: - api.user_create(userWithBadEmail) - raise e - - with pytest.raises(Exception) as e: - api.user_create(userWithBadPassword) - raise e - - with pytest.raises(Exception) as e: - api.user_create(userWithBadName) - raise e - - api.user_create(user) - api.user_login(user) + with pytest.raises(Exception): + backend.user_create(userWithBadEmail) + with pytest.raises(Exception): + backend.user_create(userWithBadPassword) + + resultUser = backend.user_create(user) + + #should not create user with same email + with pytest.raises(Exception): + backend.user_create(user) + + assert resultUser.email == user.email + assert resultUser.id != "" + +def test_login_user(): + backend = BackendApi(Requests(backendUrl)) + + # consumer = KafkaConsumer( + # 'backend_events', + # group_id='test-group', + # bootstrap_servers=['localhost:9092'], + # consumer_timeout_ms=1000) + # consumer.seek_to_end() + + user = backend.user_create(User.random()) + + with pytest.raises(Exception): + backend.user_login(user.email, "badpassword") + with pytest.raises(Exception): + backend.user_login(rand_email(), user.password) + + #should not login without verified email + with pytest.raises(Exception): + backend.user_login(user.email, user.password) + + # msgs = consumer.poll(timeout_ms=100) + # print(msgs) - def test_login_user(self): - api = BackendApi(Requests(backendUrl)) - user = api.user_create() - api.user_login(user) \ No newline at end of file diff --git a/tests/performance/dummy.py b/tests/performance/dummy.py deleted file mode 100644 index 8d5f08d..0000000 --- a/tests/performance/dummy.py +++ /dev/null @@ -1,15 +0,0 @@ -from locust import FastHttpUser, task - -from api import BackendApi, Auth - -class DummyGet(FastHttpUser): - def on_start(self): - self.api = BackendApi(self.client) - user = self.api.user_create() - self.auth = self.api.user_login(user) - - @task - def dummy_test(self): - self.api.dummy_get(self.auth) - - \ No newline at end of file diff --git a/tests/performance/health.py b/tests/performance/health.py deleted file mode 100644 index 8c05304..0000000 --- a/tests/performance/health.py +++ /dev/null @@ -1,11 +0,0 @@ -from locust import FastHttpUser, task - -from api import BackendApi - -class HealthGet(FastHttpUser): - def on_start(self): - self.api = BackendApi(self.client) - - @task - def user_create_test(self): - self.api.health_get() \ No newline at end of file