fix email verifying, add fake smtp server

This commit is contained in:
Sergey Chubaryan 2025-02-17 09:45:46 +03:00
parent fe538e631f
commit f7096afaa5
14 changed files with 157 additions and 102 deletions

View File

@ -155,6 +155,7 @@ func (a *App) Run(p RunParams) {
JwtCache: jwtCache, JwtCache: jwtCache,
EventRepo: *eventRepo, EventRepo: *eventRepo,
ActionTokenRepo: actionTokenRepo, ActionTokenRepo: actionTokenRepo,
Logger: logger,
}, },
) )
shortlinkService = services.NewShortlinkSevice( shortlinkService = services.NewShortlinkSevice(

View File

@ -1,5 +1,5 @@
port: 8080 port: 8080
postgres_url: "postgres://postgres:postgres@localhost:5432/postgres" postgres_url: "postgres://postgres:postgres@localhost:5432/postgres"
jwt_signing_key: "./jwt_signing_key" jwt_signing_key: "./jwt_signing_key"
kafka_url: "localhost:9092" kafka_url: "localhost:9091"
kafka_topic: "backend_events" kafka_topic: "events"

View File

@ -31,10 +31,9 @@ func NewUserCreateHandler(log logger.Logger, userService services.UserService) g
Name: input.Name, Name: input.Name,
}, },
) )
out := createUserOutput{}
if err != nil { if err != nil {
return out, err return createUserOutput{}, err
} }
return createUserOutput{ return createUserOutput{

View File

@ -2,7 +2,9 @@ package handlers
import ( import (
"backend/internal/core/services" "backend/internal/core/services"
httpserver "backend/internal/http_server"
"backend/pkg/logger" "backend/pkg/logger"
"context"
"html/template" "html/template"
@ -69,3 +71,16 @@ func NewUserVerifyEmailHandler(log logger.Logger, userService services.UserServi
c.Status(200) 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
},
)
}

View File

@ -48,6 +48,7 @@ func NewServer(opts NewServerOpts) *httpserver.Server {
{ {
userGroup.POST("/create", handlers.NewUserCreateHandler(opts.Logger, opts.UserService)) userGroup.POST("/create", handlers.NewUserCreateHandler(opts.Logger, opts.UserService))
userGroup.POST("/login", handlers.NewUserLoginHandler(opts.Logger, opts.UserService)) userGroup.POST("/login", handlers.NewUserLoginHandler(opts.Logger, opts.UserService))
userGroup.POST("/send-verify", handlers.NewUserSendVerifyEmailHandler(opts.Logger, opts.UserService))
} }

View File

@ -1,11 +1,13 @@
app: app:
serviceUrl: "https://localhost:8080" serviceUrl: "http://localhost:8080"
kafka: kafka:
brokers: brokers:
- localhost:9092 - localhost:9091
topic: backend_events topic: events
consumerGroupId: AAAA
smtp: smtp:
server: smtp.yandex.ru server: localhost
port: 587 port: 12333
email: "" login: "maillogin"
password: "" password: "mailpass"
email: "coworker@example.com"

View File

@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template"
"io" "io"
"log" "log"
"os" "os"
@ -21,19 +22,21 @@ const MSG_TEXT = `
</head> </head>
<body> <body>
<p>This message was sent because you forgot a password</p> <p>This message was sent because you forgot a password</p>
<p>To change a password, use <a href="{{Link}}"/>this</a> link</p> <p>To change a password, use <a href="{{.Link}}">this</a>link</p>
</body> </body>
</html> </html>
` `
func SendEmailForgotPassword(dialer *gomail.Dialer, from, to, link string) error { type HtmlTemplate struct {
msgText := strings.ReplaceAll(MSG_TEXT, "{{Link}}", link) Link string
}
func SendEmailForgotPassword(dialer *gomail.Dialer, from, to, body string) error {
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", m.FormatAddress(from, "Pet Backend")) m.SetHeader("From", m.FormatAddress(from, "Pet Backend"))
m.SetHeader("To", to) m.SetHeader("To", to)
m.SetHeader("Subject", "Hello!") m.SetHeader("Subject", "Hello!")
m.SetBody("text/html", msgText) m.SetBody("text/html", body)
return dialer.DialAndSend(m) return dialer.DialAndSend(m)
} }
@ -53,8 +56,9 @@ type Config struct {
SMTP struct { SMTP struct {
Server string `yaml:"server"` Server string `yaml:"server"`
Port int `yaml:"port"` Port int `yaml:"port"`
Email string `yaml:"email"` Login string `yaml:"login"`
Password string `yaml:"password"` Password string `yaml:"password"`
Email string `yaml:"email"`
} `yaml:"smtp"` } `yaml:"smtp"`
} }
@ -71,7 +75,7 @@ func main() {
log.Fatal(err.Error()) 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{ r := kafka.NewReader(kafka.ReaderConfig{
Brokers: config.Kafka.Brokers, Brokers: config.Kafka.Brokers,
@ -92,6 +96,11 @@ func main() {
logger.Printf("coworker service started\n") logger.Printf("coworker service started\n")
template, err := template.New("verify-email").Parse(MSG_TEXT)
if err != nil {
log.Fatal(err)
}
for { for {
msg, err := r.FetchMessage(ctx) msg, err := r.FetchMessage(ctx)
if err == io.EOF { if err == io.EOF {
@ -120,10 +129,16 @@ func main() {
continue 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 { builder := &strings.Builder{}
log.Fatalf("failed to send email: %s\n", err.Error()) 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 continue
} }
} }

View File

@ -104,25 +104,26 @@ services:
kafka: kafka:
image: &kafkaImage apache/kafka:3.8.0 image: &kafkaImage apache/kafka:3.8.0
healthcheck: 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 interval: 1s
timeout: 30s timeout: 30s
retries: 30 retries: 30
environment: environment:
KAFKA_NODE_ID: 1 KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 KAFKA_INTER_BROKER_LISTENER_NAME: BROKER
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT KAFKA_LISTENERS: BACKEND://0.0.0.0:9091, BROKER://kafka:9092, CONTROLLER://kafka:9093
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost: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_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_NUM_PARTITIONS: 3 KAFKA_NUM_PARTITIONS: 3
ports: ports:
- 9092:9092 - 9091:9091
kafka-init: kafka-init:
image: *kafkaImage image: *kafkaImage
@ -162,6 +163,22 @@ services:
exit 0; 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: volumes:
postgres-volume: postgres-volume:
grafana-volume: grafana-volume:

View File

@ -5,6 +5,7 @@ import (
"backend/internal/core/repos" "backend/internal/core/repos"
"backend/internal/core/utils" "backend/internal/core/utils"
"backend/pkg/cache" "backend/pkg/cache"
"backend/pkg/logger"
"context" "context"
"fmt" "fmt"
"time" "time"
@ -33,7 +34,7 @@ type UserService interface {
VerifyEmail(ctx context.Context, actionToken string) error VerifyEmail(ctx context.Context, actionToken string) error
SendEmailForgotPassword(ctx context.Context, userId 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 ChangePassword(ctx context.Context, userId, oldPassword, newPassword string) error
ChangePasswordWithToken(ctx context.Context, userId, actionToken, newPassword string) error ChangePasswordWithToken(ctx context.Context, userId, actionToken, newPassword string) error
@ -51,6 +52,7 @@ type UserServiceDeps struct {
JwtCache cache.Cache[string, string] JwtCache cache.Cache[string, string]
EventRepo repos.EventRepo EventRepo repos.EventRepo
ActionTokenRepo repos.ActionTokenRepo ActionTokenRepo repos.ActionTokenRepo
Logger logger.Logger
} }
type userService struct { type userService struct {
@ -91,7 +93,10 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (
if err != nil { if err != nil {
return nil, err 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}) 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 { if err != nil {
return err 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) return u.sendEmailVerifyEmail(ctx, user.Id, user.Email)
} }

View File

@ -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) msg := fmt.Sprintf("Request %s %s %d %v", method, path, statusCode, latency)
if statusCode >= 200 && statusCode < 400 { if statusCode >= 200 && statusCode < 400 {
// ctxLogger.Log().Msg(msg) ctxLogger.Log().Msg(msg)
return return
} }

View File

@ -11,7 +11,6 @@ class Requests():
class Auth(): class Auth():
token: string token: string
def __init__(self, token): def __init__(self, token):
self.token = token self.token = token
@ -21,21 +20,30 @@ class User():
name: string name: string
password: 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.email = email
self.password = password self.password = password
self.name = name 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(): class BackendApi():
def __init__(self, httpClient): def __init__(self, httpClient):
self.httpClient = httpClient self.httpClient = httpClient
def parse_response(self, response): def parse_response(self, response):
if response.status != 200: if response.status_code != 200:
raise AssertionError('Request error') raise AssertionError('Request error')
json = response.json() json = response.json()
if json['status'] == 'success': if json['status'] == 'success':
if 'result' in json: if 'result' in json:
@ -45,35 +53,27 @@ class BackendApi():
error = json['error'] error = json['error']
raise AssertionError(error['id'], error['message']) raise AssertionError(error['id'], error['message'])
def user_create(self, user: User | None) -> User: def user_create(self, user: User) -> 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)
res = self.parse_response( res = self.parse_response(
self.httpClient.post( self.httpClient.post(
"/v1/user/create", json={ "/api/v1/user/create", json={
"email": user.email, "email": user.email,
"password": user.password, "password": user.password,
"name": user.name, "name": user.name,
} }
) )
) )
return User(res['email'], user.password, res['name'], id=res['id'])
return User(res['email'], res['password'], res['name'], res['id'])
def user_login(self, user: User) -> Auth: def user_login(self, email, password) -> Auth:
res = self.parse_response( res = self.parse_response(
self.httpClient.post( self.httpClient.post(
"/v1/user/login", json={ "/api/v1/user/login", json={
"email": user.email+"a", "email": email,
"password": user.password, "password": password,
}, },
) )
) )
return Auth(res['status']) return Auth(res['status'])
def dummy_get(self, auth: Auth): def dummy_get(self, auth: Auth):

View File

@ -1,33 +1,53 @@
import random
import string
import pytest import pytest
from api import BackendApi, Requests, User from kafka import KafkaConsumer
from api import BackendApi, Requests, User, rand_email
backendUrl = "http://localhost:8080" backendUrl = "http://localhost:8080"
class TestUser: def test_create_user():
def test_create_user(self): backend = BackendApi(Requests(backendUrl))
api = BackendApi(Requests(backendUrl))
user = User("user@example.com", "aaaaaA1!", "SomeName") user = User.random()
userWithBadEmail = User("example.com", "aaaaaA1!", "SomeName") userWithBadEmail = User("sdfsaadsfgdf", user.password, user.name)
userWithBadPassword = User("user@example.com", "badPassword", "SomeName") userWithBadPassword = User(user.email, "badPassword", user.name)
userWithBadName = User("user@example.com", "aaaaaA1!", "")
with pytest.raises(Exception) as e: with pytest.raises(Exception):
api.user_create(userWithBadEmail) backend.user_create(userWithBadEmail)
raise e with pytest.raises(Exception):
backend.user_create(userWithBadPassword)
with pytest.raises(Exception) as e:
api.user_create(userWithBadPassword) resultUser = backend.user_create(user)
raise e
#should not create user with same email
with pytest.raises(Exception) as e: with pytest.raises(Exception):
api.user_create(userWithBadName) backend.user_create(user)
raise e
assert resultUser.email == user.email
api.user_create(user) assert resultUser.id != ""
api.user_login(user)
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)

View File

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

View File

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