Files
test_deploy/internal/http/auth.go
Alex Shevchuk 61fc0d2747
Some checks failed
Deploy Production / Deploy to Staging (push) Has been skipped
Go Linter / Run golangci-lint (api_gateway) (push) Failing after 2m31s
Go Linter / Build golang services (api_gateway) (push) Has been skipped
Go Linter / Tag Commit (push) Has been skipped
Go Linter / Push Docker Images (api_gateway) (push) Has been skipped
71
2025-09-17 14:32:06 +03:00

644 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package http_router
import (
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/auth"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/auth/keycloak"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/broker"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/constants"
dbtypes "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/database/types"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/feed"
notification "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/notifications/git-molva.ru/Molva/molva-notification-service"
rmodel "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/request_model"
authinfra "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/services/auth_infrastructure"
"google.golang.org/protobuf/proto"
)
// ------------------------------
// LOGIN USER
// ------------------------------
// @Summary Вход пользователя
// @Description Аутентификация пользователя по email и паролю
// @Tags auth
// @Accept json
// @Produce json
// @Param request body rmodel.LoginUserRequest true "Данные для входа"
// @Success 200 {object} rmodel.LoginUserResponse "Успешная аутентификация"
// @Failure 400 {object} map[string]string "Неверные данные запроса"
// @Failure 401 {object} map[string]string "Неверные учетные данные"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Router /api/v1/login [post]
func (h *handler) loginHandler(w http.ResponseWriter, r *http.Request) {
const handlerName = "loginHandler"
var request rmodel.LoginUserRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, constants.ErrBadRequest.Error(), http.StatusBadRequest)
h.logger.Error("Invalid request body",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
defer func(body io.ReadCloser) {
if err := body.Close(); err != nil {
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
h.logger.Error("error closing body",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
}
}(r.Body)
tokens, err := h.authManager.LoginUser(r.Context(), auth.LoginUserRequest{
Email: request.Email,
Password: request.Password,
})
if err != nil {
h.handleKeycloakError(w, err)
h.logger.Error("error while login user",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
userInfo, err := h.authManager.GetUserInfo(r.Context(), tokens.UserId)
if err != nil {
h.handleKeycloakError(w, err)
h.logger.Error("error while getting user info",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
emailVerificationStatus, err := h.authManager.GetEmailVerificationStatus(r.Context(), tokens.UserId)
if err != nil {
h.handleKeycloakError(w, err)
h.logger.Error("error while getting email verification status",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
w.Header().Set("Content-Type", "application/json")
if err = json.NewEncoder(w).Encode(rmodel.LoginUserResponse{
Uid: tokens.UserId,
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
UserType: userInfo.UserType,
Permissions: rmodel.Permissions{
Balance: userInfo.Permissions[keycloak.PermissionBalance],
Company: userInfo.Permissions[keycloak.PermissionCompany],
Employees: userInfo.Permissions[keycloak.PermissionEmployees],
Profile: userInfo.Permissions[keycloak.PermissionProfile],
Submissions: userInfo.Permissions[keycloak.PermissionSubmissions],
Vacancies: userInfo.Permissions[keycloak.PermissionVacancies],
},
EmailVerified: emailVerificationStatus.EmailVerified,
}); err != nil {
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
h.logger.Error("error while encoding response: ",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
}
}
// ------------------------------
// REGISTER USER
// ------------------------------
// @Summary Регистрация пользователя
// @Description Создание нового пользователя в системе
// @Tags auth
// @Accept json
// @Produce json
// @Param request body rmodel.UserCredentials true "Данные для регистрации"
// @Success 201 {object} rmodel.RegisterResponse "Пользователь успешно создан"
// @Failure 400 {object} map[string]string "Неверные данные запроса"
// @Failure 409 {object} map[string]string "Пользователь уже существует"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Router /api/v1/register [post]
func (h *handler) registerHandler(w http.ResponseWriter, r *http.Request) {
const handlerName = "registerHandler"
var creds rmodel.UserCredentials
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
http.Error(w, constants.ErrBadRequest.Error(), http.StatusBadRequest)
h.logger.Error("error decoding request body",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
defer r.Body.Close()
if h.env == "production" {
isEmailValid, err := h.validateEmail(creds.Email)
if err != nil {
h.logger.Warn("EmailVerificationService API service error, proceeding with email validation via message",
slog.String("error", err.Error()),
slog.String("email", creds.Email),
slog.String("handler", handlerName))
} else if !isEmailValid {
h.logger.Warn("email validation failed - invalid email address",
slog.String("email", creds.Email),
slog.String("handler", handlerName))
http.Error(w, "Invalid email address", http.StatusBadRequest)
return
}
}
uid, err := h.authManager.RegisterUser(r.Context(), auth.RegisterUserRequest{
User: auth.User{
Email: creds.Email,
Password: creds.Password,
FirstName: creds.FirstName,
SecondName: creds.LastName,
Patronymic: creds.MiddleName,
Number: creds.PhoneNumber,
Permissions: creds.Permissions,
},
UserType: creds.UserType,
})
if err != nil {
h.logger.Error("error while saving user to keycloak",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
h.handleKeycloakError(w, err)
return
}
if err := h.saveUser(r.Context(), uid.UserId, creds); err != nil {
h.handleDBError(w, err)
h.logger.Error("error saving user to DB",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
tokenResp, err := h.authManager.GetUserEmailVerificationToken(r.Context(), uid.UserId)
if err != nil {
h.logger.Error("error getting user token",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
return
}
userName := formatName(creds.LastName, creds.FirstName, creds.MiddleName)
if err := h.sendConfirmationEmail(r, creds.Email, userName, uid.UserId, tokenResp.AccessToken); err != nil {
h.logger.Error("error sending confirmation email",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
return
}
h.createWelcomeEvent(r.Context(), uid.UserId, creds, handlerName)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(&rmodel.RegisterResponse{UUID: uid.UserId}); err != nil {
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
h.logger.Error("error while encoding response", slog.String("error", err.Error()),
slog.String("handler", handlerName))
}
}
func (h *handler) createWelcomeEvent(ctx context.Context, userID string, creds rmodel.UserCredentials, handlerName string) {
ownerType := feed.RoleAgent
if creds.UserType == constants.DistributorClientType {
ownerType = feed.RoleDistributor
}
event := feed.Event{
OwnerId: userID,
OwnerType: ownerType,
EventType: feed.EventWelcome,
Message: "Добро пожаловать в Molva!",
Visibility: feed.VisibilityPrivate,
Payload: feed.EventPayload{},
}
if err := h.feed.CreateEvent(ctx, &event); err != nil {
h.logger.Error("failed to create feed event",
slog.String("error", err.Error()),
slog.String("handler", handlerName),
slog.String("uid", userID))
}
}
func (h *handler) sendConfirmationEmail(r *http.Request, email, userName, userID, accessToken string) error {
scheme := "https"
if r.TLS == nil {
scheme = "http"
}
confirmURL := fmt.Sprintf("%s://%s/api/v1/confirm_email?uid=%s&token=%s",
scheme,
r.Host,
url.QueryEscape(userID),
url.QueryEscape(accessToken),
)
emailBody := fmt.Sprintf(constants.EmailVerificationTemplate,
userName,
confirmURL,
confirmURL,
)
msg, err := proto.Marshal(&notification.SendEmailRequest{
To: []string{email},
Subject: constants.EmailVerificationMessageSubject,
ContentType: constants.HTMLNotificationContentType,
Body: []byte(emailBody),
})
if err != nil {
return fmt.Errorf("marshaling email message: %w", err)
}
if err := broker.SendNotification(
broker.NotificationQueue,
constants.EmailNotificationMessageType,
msg,
h.logger,
); err != nil {
return fmt.Errorf("sending notification: %w", err)
}
return nil
}
// NOTE: lastName and firstName are required to be non-empty
func formatName(lastName, firstName string, middleName *string) string {
name := fmt.Sprintf("%s %s", lastName, firstName)
if middleName != nil {
name = fmt.Sprintf("%s %s", name, *middleName)
}
return name
}
func (h *handler) saveUser(ctx context.Context, uid string, creds rmodel.UserCredentials) error {
userName := formatName(creds.LastName, creds.FirstName, creds.MiddleName)
if _, err := h.dbClient.CreateUser(ctx, &dbtypes.UserSaveRequest{
Id: uid,
FullName: userName,
Phone: creds.PhoneNumber,
Email: creds.Email,
Type: dbtypes.UserType(creds.UserType),
}); err != nil {
return err
}
return nil
}
// ------------------------------
// LOGOUT USER
// ------------------------------
// @Summary Выход пользователя
// @Description Завершение сессии пользователя
// @Tags auth
// @Accept json
// @Produce json
// @Param request body rmodel.LogoutUserRequest true "Токен для выхода"
// @Success 200 "Успешный выход"
// @Failure 400 {object} map[string]string "Неверные данные запроса"
// @Failure 401 {object} map[string]string "Неверный токен"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Router /api/v1/logout [post]
func (h *handler) logoutHandler(w http.ResponseWriter, r *http.Request) {
const handlerName = "logoutHandler"
var request rmodel.LogoutUserRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusBadRequest)
h.logger.Error("error while decoding request body: ",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
if err := h.authManager.LogoutUser(r.Context(), auth.LogoutUserRequest{
RefreshToken: request.RefreshToken,
}); err != nil {
h.handleKeycloakError(w, err)
return
}
}
// ------------------------------
// REFRESH USER TOKEN
// ------------------------------
// @Summary Обновление токена доступа
// @Description Получение нового access token по refresh token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body rmodel.RefreshTokenRequest true "Refresh token"
// @Success 200 {object} rmodel.RefreshTokenResponse "Новые токены"
// @Failure 400 {object} map[string]string "Неверные данные запроса"
// @Failure 401 {object} map[string]string "Неверный refresh token"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Router /api/v1/refresh-token [post]
func (h *handler) refreshTokenHandler(w http.ResponseWriter, r *http.Request) {
const handlerName = "refreshTokenHandler"
var request rmodel.RefreshTokenRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusBadRequest)
h.logger.Error("error while decoding request body: ",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
resp, err := h.authManager.GetNewAccessToken(r.Context(), auth.GetNewAccessTokenRequest{
RefreshToken: request.RefreshToken,
})
if err != nil {
h.handleKeycloakError(w, err)
return
}
if err := json.NewEncoder(w).Encode(rmodel.RefreshTokenResponse{
AccessToken: resp.AccessToken,
RefreshToken: resp.RefreshToken,
}); err != nil {
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
h.logger.Error("error while encoding response: ",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
}
// @Summary Страница подтверждения email
// @Description HTML страница для подтверждения email адреса пользователя
// @Tags auth
// @Accept html
// @Produce html
// @Param uid query string true "ID пользователя"
// @Param token query string true "Токен подтверждения"
// @Success 200 {string} string "HTML страница подтверждения"
// @Failure 400 {object} map[string]string "Неверные параметры запроса"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Router /api/v1/confirm_email [get]
func (h *handler) confirmEmailPageHandler(w http.ResponseWriter, r *http.Request) {
const handlerName = "confirmEmailPageHandler"
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
uid := r.URL.Query().Get("uid")
token := r.URL.Query().Get("token")
if uid == "" || token == "" {
http.Error(w, "Bad Request: отсутствует uid или token", http.StatusBadRequest)
return
}
scheme := "http"
if protocol := r.Header.Get("X-Forwarded-Proto"); protocol != "" {
scheme = protocol
} else if h.env == "production" || h.env == "development" {
scheme = "https"
}
// Формируем URL для PATCH-запроса
verificationLink := fmt.Sprintf("%s://%s/api/v1/verify_email?uid=%s&token=%s",
scheme,
r.Host,
url.QueryEscape(uid),
url.QueryEscape(token),
)
tmpl, err := template.New("confirm").Parse(constants.EmailConfirmationPage)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, struct {
VerificationURL string
}{
VerificationURL: verificationLink,
}); err != nil {
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
h.logger.Error("error while executing confirm email page template",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
}
}
// ------------------------------
// RESET PASSWORD
// ------------------------------
// @Summary Запрос восстановления пароля
// @Description Отправка OTP кода на email для восстановления пароля
// @Tags auth
// @Accept json
// @Produce json
// @Param request body rmodel.ForgotPasswordRequest true "Данные для восстановления пароля"
// @Success 200 {object} map[string]string "OTP код отправлен"
// @Failure 400 {object} map[string]string "Неверные данные запроса"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Router /api/v1/forgot_password [post]
func (h *handler) forgotPasswordHandler(w http.ResponseWriter, r *http.Request) {
const handlerName = "forgotPasswordHandler"
var request rmodel.ForgotPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, constants.ErrBadRequest.Error(), http.StatusBadRequest)
h.logger.Error("error while decoding request body: ",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
createOTPResp, err := h.authInfraService.CreatePasswordResetOTP(r.Context(), &authinfra.PasswordResetOTPCreateRequest{
Email: request.Email,
})
if err != nil {
h.handleAuthInfraError(w, err, handlerName)
return
}
message, err := proto.Marshal(&notification.SendEmailRequest{
To: []string{request.Email},
Subject: constants.ForgotPasswordNotificationMessageSubject,
ContentType: constants.TextNotificationContentType,
Body: fmt.Appendf(nil,
constants.ForgotPasswordNotificationText,
createOTPResp.OTP,
),
})
if err != nil {
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
h.logger.Error("error while marshaling forgot password notification message",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
if err := broker.SendNotification(
broker.NotificationQueue,
constants.EmailNotificationMessageType,
message,
h.logger,
); err != nil {
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
h.logger.Error("error while sending forgot password notification",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
w.WriteHeader(http.StatusCreated)
}
// @Summary Валидация OTP кода
// @Description Проверка OTP кода для восстановления пароля
// @Tags auth
// @Accept json
// @Produce json
// @Param otp query string true "OTP код"
// @Param email query string true "Email пользователя"
// @Success 200 {object} map[string]string "OTP код валиден"
// @Failure 400 {object} map[string]string "Неверные параметры запроса"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Router /api/v1/validate_otp [get]
func (h *handler) validateOTPHandler(w http.ResponseWriter, r *http.Request) {
const handlerName = "validateOTPHandler"
query := r.URL.Query()
email := query.Get("email")
if email == "" {
http.Error(w, constants.ErrBadRequest.Error(), http.StatusBadRequest)
h.logger.Debug("email is required, but missing",
slog.String("handler", handlerName))
return
}
otp := query.Get("otp")
if otp == "" {
http.Error(w, constants.ErrBadRequest.Error(), http.StatusBadRequest)
h.logger.Debug("OTP is required, but missing",
slog.String("handler", handlerName))
return
}
checkOTPResp, err := h.authInfraService.ValidatePasswordResetOTP(r.Context(), &authinfra.ValidatePasswordResetOTPRequest{
Email: email,
OTP: otp,
})
if err != nil {
h.handleAuthInfraError(w, err, handlerName)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(rmodel.ValidateOTPResponse{
Token: checkOTPResp.Token,
}); err != nil {
http.Error(w, constants.ErrInternalServerError.Error(), http.StatusInternalServerError)
h.logger.Error("error encoding response: ",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
}
}
// @Summary Сброс пароля
// @Description Установка нового пароля после валидации OTP кода
// @Tags auth
// @Accept json
// @Produce json
// @Param request body rmodel.ResetPasswordRequest true "Данные для сброса пароля"
// @Success 200 {object} map[string]string "Пароль успешно изменен"
// @Failure 400 {object} map[string]string "Неверные данные запроса"
// @Failure 500 {object} map[string]string "Внутренняя ошибка сервера"
// @Router /api/v1/reset_password [put]
func (h *handler) resetPasswordHandler(w http.ResponseWriter, r *http.Request) {
const handlerName = "resetPasswordHandler"
var request rmodel.ResetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, constants.ErrBadRequest.Error(), http.StatusBadRequest)
h.logger.Error("error decoding request body: ",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
if _, err := h.authInfraService.ValidatePasswordResetToken(r.Context(), &authinfra.ValidatePasswordResetTokenRequest{
Email: request.Email,
Token: request.Token,
}); err != nil {
h.handleAuthInfraError(w, err, handlerName)
return
}
if err := h.authManager.ResetPassword(r.Context(), auth.ResetPasswordRequest{
Email: request.Email,
NewPassword: request.Password,
}); err != nil {
h.handleKeycloakError(w, err)
h.logger.Error("error resetting password",
slog.String("error", err.Error()),
slog.String("handler", handlerName))
return
}
w.WriteHeader(http.StatusNoContent)
}