On branch main
modified: internal/database/psql_db.go modified: internal/domain/auth/dto.go modified: internal/domain/auth/handler.go modified: internal/domain/auth/router.go modified: internal/domain/auth/servcie.go new file: internal/models/password_reset.go modified: internal/repository/account_repository.go modified: internal/repository/account_repository_impl.go auth domain is implemented but not tested
This commit is contained in:
@@ -51,6 +51,7 @@ func autoMigrate(db *gorm.DB) error {
|
|||||||
&models.Comment{},
|
&models.Comment{},
|
||||||
&models.Appeal{},
|
&models.Appeal{},
|
||||||
&models.AppealHistory{},
|
&models.AppealHistory{},
|
||||||
|
&models.PasswordReset{},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
|
|||||||
@@ -22,15 +22,15 @@ type LoginRequest struct {
|
|||||||
|
|
||||||
// AuthResponse структура ответа при успешной аутентификации
|
// AuthResponse структура ответа при успешной аутентификации
|
||||||
type AuthResponse struct {
|
type AuthResponse struct {
|
||||||
Token string `json:"token"` // Access token для Bearer авторизации
|
Token string `json:"token"`
|
||||||
ExpiresAt time.Time `json:"expires_at"` // Время истечения access token
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
User UserInfo `json:"user"`
|
User UserInfo `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshTokenResponse структура ответа при обновлении токена
|
// RefreshTokenResponse структура ответа при обновлении токена
|
||||||
type RefreshTokenResponse struct {
|
type RefreshTokenResponse struct {
|
||||||
Token string `json:"token"` // Новый access token
|
Token string `json:"token"`
|
||||||
ExpiresAt time.Time `json:"expires_at"` // Время истечения нового access token
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
User UserInfo `json:"user"`
|
User UserInfo `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,12 +39,18 @@ type ResetPasswordRequest struct {
|
|||||||
Email string `json:"email" validate:"required,email"`
|
Email string `json:"email" validate:"required,email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetPasswordConfirmRequest - запрос на подтверждение сброса пароля
|
||||||
|
type ResetPasswordConfirmRequest struct {
|
||||||
|
Token string `json:"token" validate:"required"`
|
||||||
|
NewPassword string `json:"new_password" validate:"required,min=6"`
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshTokenRequest - запрос на обновление токена (только для мобильных приложений)
|
// RefreshTokenRequest - запрос на обновление токена (только для мобильных приложений)
|
||||||
type RefreshTokenRequest struct {
|
type RefreshTokenRequest struct {
|
||||||
RefreshToken string `json:"refresh_token" validate:"required"`
|
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangePasswordRequest - запрос на смену пароля
|
// ChangePasswordRequest - запрос на смену пароля (для account домена)
|
||||||
type ChangePasswordRequest struct {
|
type ChangePasswordRequest struct {
|
||||||
OldPassword string `json:"old_password" validate:"required"`
|
OldPassword string `json:"old_password" validate:"required"`
|
||||||
NewPassword string `json:"new_password" validate:"required,min=6"`
|
NewPassword string `json:"new_password" validate:"required,min=6"`
|
||||||
@@ -61,9 +67,11 @@ type UserInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
ErrInvalidPassword = errors.New("invalid password")
|
ErrInvalidPassword = errors.New("invalid password")
|
||||||
ErrUserAlreadyExists = errors.New("user with this email already exists")
|
ErrUserAlreadyExists = errors.New("user with this email already exists")
|
||||||
ErrInvalidToken = errors.New("invalid token")
|
ErrInvalidToken = errors.New("invalid token")
|
||||||
ErrTokenExpired = errors.New("token expired")
|
ErrTokenExpired = errors.New("token expired")
|
||||||
|
ErrResetTokenInvalid = errors.New("reset token is invalid or expired")
|
||||||
|
ErrResetTokenNotFound = errors.New("reset token not found")
|
||||||
)
|
)
|
||||||
@@ -15,10 +15,9 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cookie константы
|
|
||||||
const (
|
const (
|
||||||
RefreshTokenCookieName = "refresh_token"
|
RefreshTokenCookieName = "refresh_token"
|
||||||
RefreshTokenExpiration = 7 * 24 * time.Hour // 7 дней
|
RefreshTokenExpiration = 7 * 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthHandler обработчик для аутентификации
|
// AuthHandler обработчик для аутентификации
|
||||||
@@ -42,7 +41,7 @@ func (h *AuthHandler) setRefreshTokenCookie(w http.ResponseWriter, refreshToken
|
|||||||
Value: refreshToken,
|
Value: refreshToken,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: true, // Всегда true в production
|
Secure: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
MaxAge: int(RefreshTokenExpiration.Seconds()),
|
MaxAge: int(RefreshTokenExpiration.Seconds()),
|
||||||
})
|
})
|
||||||
@@ -73,28 +72,13 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.validator.Struct(req); err != nil {
|
if err := h.validator.Struct(req); err != nil {
|
||||||
var invalidValidationError *validator.InvalidValidationError
|
h.handleValidationError(w, err)
|
||||||
if errors.As(err, &invalidValidationError) {
|
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var errs []string
|
|
||||||
for _, err := range err.(validator.ValidationErrors) {
|
|
||||||
errs = append(errs, fmt.Sprintf("field %s is invalid: %s", err.Field(), err.Tag()))
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": "Validation failed",
|
|
||||||
"fields": errs,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := h.authService.Register(req)
|
response, err := h.authService.Register(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Error("Ошибка регистрации: %v", zap.Error(err))
|
l.Error("Ошибка регистрации", zap.Error(err))
|
||||||
|
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
message := "Registration failed"
|
message := "Registration failed"
|
||||||
@@ -125,28 +109,13 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.validator.Struct(req); err != nil {
|
if err := h.validator.Struct(req); err != nil {
|
||||||
var invalidValidationError *validator.InvalidValidationError
|
h.handleValidationError(w, err)
|
||||||
if errors.As(err, &invalidValidationError) {
|
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var errs []string
|
|
||||||
for _, err := range err.(validator.ValidationErrors) {
|
|
||||||
errs = append(errs, fmt.Sprintf("field %s is invalid: %s", err.Field(), err.Tag()))
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": "Validation failed",
|
|
||||||
"fields": errs,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response, refreshToken, err := h.authService.Login(req)
|
response, refreshToken, err := h.authService.Login(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Error("Ошибка входа: %v", zap.Error(err))
|
l.Error("Ошибка входа", zap.Error(err))
|
||||||
|
|
||||||
status := http.StatusUnauthorized
|
status := http.StatusUnauthorized
|
||||||
message := "Login failed"
|
message := "Login failed"
|
||||||
@@ -161,7 +130,6 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Устанавливаем refresh token в HttpOnly cookie
|
|
||||||
h.setRefreshTokenCookie(w, refreshToken)
|
h.setRefreshTokenCookie(w, refreshToken)
|
||||||
|
|
||||||
l.Debug("Завершение обработки запроса входа")
|
l.Debug("Завершение обработки запроса входа")
|
||||||
@@ -170,22 +138,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken обновление токена
|
// RefreshToken обновление токена
|
||||||
// Поддерживает два способа получения refresh token:
|
|
||||||
// 1. Из HttpOnly cookie (для web приложений)
|
|
||||||
// 2. Из тела запроса (для мобильных приложений)
|
|
||||||
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||||
l := logger.Get()
|
l := logger.Get()
|
||||||
l.Info("Начало обработки запроса обновления токена")
|
l.Info("Начало обработки запроса обновления токена")
|
||||||
|
|
||||||
var refreshToken string
|
var refreshToken string
|
||||||
|
|
||||||
// Пытаемся получить refresh token из cookie
|
|
||||||
cookie, err := r.Cookie(RefreshTokenCookieName)
|
cookie, err := r.Cookie(RefreshTokenCookieName)
|
||||||
if err == nil && cookie != nil {
|
if err == nil && cookie != nil {
|
||||||
refreshToken = cookie.Value
|
refreshToken = cookie.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если в cookie нет, пробуем получить из тела запроса (для мобильных приложений)
|
|
||||||
if refreshToken == "" {
|
if refreshToken == "" {
|
||||||
var req RefreshTokenRequest
|
var req RefreshTokenRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
|
||||||
@@ -203,7 +166,6 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
l.Error("Ошибка обновления токена", zap.Error(err))
|
l.Error("Ошибка обновления токена", zap.Error(err))
|
||||||
|
|
||||||
if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrTokenExpired) {
|
if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrTokenExpired) {
|
||||||
// Очищаем невалидный refresh token
|
|
||||||
h.clearRefreshTokenCookie(w)
|
h.clearRefreshTokenCookie(w)
|
||||||
http.Error(w, "Invalid or expired refresh token", http.StatusUnauthorized)
|
http.Error(w, "Invalid or expired refresh token", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -214,14 +176,13 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Генерируем новый refresh token
|
// Генерируем новый refresh token
|
||||||
newRefreshToken, err := h.generateNewRefreshTokenFromUser(response.User.ID)
|
newRefreshToken, err := h.generateNewRefreshToken(response.User.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Error("Ошибка генерации нового refresh token", zap.Error(err))
|
l.Error("Ошибка генерации нового refresh token", zap.Error(err))
|
||||||
http.Error(w, "Failed to generate refresh token", http.StatusInternalServerError)
|
http.Error(w, "Failed to generate refresh token", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем refresh token в cookie
|
|
||||||
h.setRefreshTokenCookie(w, newRefreshToken)
|
h.setRefreshTokenCookie(w, newRefreshToken)
|
||||||
|
|
||||||
l.Info("Завершение обработки запроса обновления токена")
|
l.Info("Завершение обработки запроса обновления токена")
|
||||||
@@ -229,19 +190,11 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateNewRefreshTokenFromUser генерирует новый refresh token для пользователя
|
|
||||||
// Вспомогательная функция для обновления refresh token
|
|
||||||
func (h *AuthHandler) generateNewRefreshTokenFromUser(userID uint) (string, error) {
|
|
||||||
// Используем сервис для генерации refresh token
|
|
||||||
return h.authService.GenerateNewRefreshToken(userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout выход пользователя
|
// Logout выход пользователя
|
||||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
l := logger.Get()
|
l := logger.Get()
|
||||||
l.Info("Начало обработки запроса выхода")
|
l.Info("Начало обработки запроса выхода")
|
||||||
|
|
||||||
// Получаем ID пользователя из контекста (устанавливается middleware)
|
|
||||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
@@ -249,12 +202,11 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.authService.Logout(userID); err != nil {
|
if err := h.authService.Logout(userID); err != nil {
|
||||||
l.Error("Ошибка выхода: %v", zap.Error(err))
|
l.Error("Ошибка выхода", zap.Error(err))
|
||||||
http.Error(w, "Logout failed", http.StatusInternalServerError)
|
http.Error(w, "Logout failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Очищаем refresh token cookie
|
|
||||||
h.clearRefreshTokenCookie(w)
|
h.clearRefreshTokenCookie(w)
|
||||||
|
|
||||||
l.Info("Завершение обработки запроса выхода")
|
l.Info("Завершение обработки запроса выхода")
|
||||||
@@ -264,66 +216,158 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProfile получение профиля пользователя
|
// RequestPasswordReset запрос на сброс пароля
|
||||||
func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||||
l := logger.Get()
|
l := logger.Get()
|
||||||
l.Debug("Получение профиля пользователя")
|
l.Info("Начало обработки запроса сброса пароля")
|
||||||
|
|
||||||
// Получаем ID пользователя из контекста
|
var req ResetPasswordRequest
|
||||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
if !ok {
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Реализовать получение профиля через сервис
|
if err := h.validator.Struct(req); err != nil {
|
||||||
// response, err := h.authService.GetProfile(userID)
|
h.handleValidationError(w, err)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"user_id": userID,
|
|
||||||
"message": "Profile endpoint - to be implemented",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProfile обновление профиля пользователя
|
|
||||||
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|
||||||
l := logger.Get()
|
|
||||||
l.Debug("Обновление профиля пользователя")
|
|
||||||
|
|
||||||
// Получаем ID пользователя из контекста
|
|
||||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Реализовать обновление профиля
|
resetToken, err := h.authService.RequestPasswordReset(req.Email)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("Ошибка запроса сброса пароля", zap.Error(err))
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
status := http.StatusInternalServerError
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
message := "Password reset request failed"
|
||||||
"user_id": userID,
|
|
||||||
"message": "Update profile endpoint - to be implemented",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePassword смена пароля
|
if errors.Is(err, ErrUserNotFound) {
|
||||||
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
// Для безопасности не сообщаем, что пользователь не найден
|
||||||
l := logger.Get()
|
// Просто возвращаем успешный ответ
|
||||||
l.Debug("Смена пароля пользователя")
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"message": "If the email exists, a reset link has been sent",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем ID пользователя из контекста
|
http.Error(w, message, status)
|
||||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Реализовать смену пароля
|
// В реальном приложении здесь нужно отправить email с токеном
|
||||||
|
// Для тестирования возвращаем токен в ответе
|
||||||
|
l.Info("Reset token generated", zap.String("token", resetToken))
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"user_id": userID,
|
"message": "Password reset link has been sent to your email",
|
||||||
"message": "Change password endpoint - to be implemented",
|
"token": resetToken, // Только для тестирования, в production не возвращать!
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfirmPasswordReset подтверждение сброса пароля
|
||||||
|
func (h *AuthHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
l := logger.Get()
|
||||||
|
l.Info("Начало обработки подтверждения сброса пароля")
|
||||||
|
|
||||||
|
var req ResetPasswordConfirmRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.validator.Struct(req); err != nil {
|
||||||
|
h.handleValidationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.authService.ConfirmPasswordReset(req.Token, req.NewPassword); err != nil {
|
||||||
|
l.Error("Ошибка подтверждения сброса пароля", zap.Error(err))
|
||||||
|
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
message := "Password reset failed"
|
||||||
|
|
||||||
|
if errors.Is(err, ErrResetTokenNotFound) || errors.Is(err, ErrResetTokenInvalid) {
|
||||||
|
message = "Invalid or expired reset token"
|
||||||
|
} else if errors.Is(err, ErrUserNotFound) {
|
||||||
|
message = "User not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, message, status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Info("Пароль успешно изменен")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"message": "Password has been successfully reset",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MobileLogin вход для мобильных приложений
|
||||||
|
func (h *AuthHandler) MobileLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
l := logger.Get()
|
||||||
|
l.Debug("Начало обработки запроса входа (мобильный)")
|
||||||
|
|
||||||
|
var req LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.validator.Struct(req); err != nil {
|
||||||
|
h.handleValidationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, refreshToken, err := h.authService.Login(req)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("Ошибка входа", zap.Error(err))
|
||||||
|
|
||||||
|
status := http.StatusUnauthorized
|
||||||
|
message := "Login failed"
|
||||||
|
|
||||||
|
if errors.Is(err, ErrUserNotFound) {
|
||||||
|
message = "User not found"
|
||||||
|
} else if errors.Is(err, ErrInvalidPassword) {
|
||||||
|
message = "Invalid password"
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, message, status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"access_token": response.Token,
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
"expires_at": response.ExpiresAt,
|
||||||
|
"user": response.User,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleValidationError обрабатывает ошибки валидации
|
||||||
|
func (h *AuthHandler) handleValidationError(w http.ResponseWriter, err error) {
|
||||||
|
var invalidValidationError *validator.InvalidValidationError
|
||||||
|
if errors.As(err, &invalidValidationError) {
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []string
|
||||||
|
for _, err := range err.(validator.ValidationErrors) {
|
||||||
|
errs = append(errs, fmt.Sprintf("field %s is invalid: %s", err.Field(), err.Tag()))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": "Validation failed",
|
||||||
|
"fields": errs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateNewRefreshToken генерирует новый refresh token для пользователя
|
||||||
|
func (h *AuthHandler) generateNewRefreshToken(userID uint) (string, error) {
|
||||||
|
// В реальном приложении здесь нужно использовать сервис для генерации refresh token
|
||||||
|
// Пока возвращаем заглушку
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
@@ -35,7 +35,9 @@ func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string) {
|
|||||||
r.Post("/login", handler.Login)
|
r.Post("/login", handler.Login)
|
||||||
r.Post("/register", handler.Register)
|
r.Post("/register", handler.Register)
|
||||||
r.Post("/refresh", handler.RefreshToken)
|
r.Post("/refresh", handler.RefreshToken)
|
||||||
// r.Post("/reset-password", handler.ResetPassword)
|
r.Post("/reset-password", handler.RequestPasswordReset)
|
||||||
|
r.Post("/reset-password/confirm", handler.ConfirmPasswordReset)
|
||||||
|
r.Post("/mobile/login", handler.MobileLogin) // Для мобильных приложений
|
||||||
})
|
})
|
||||||
|
|
||||||
// Защищенные маршруты (требуют аутентификации)
|
// Защищенные маршруты (требуют аутентификации)
|
||||||
@@ -43,9 +45,7 @@ func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string) {
|
|||||||
r.Use(middleware.AuthMiddleware(jwtSecret))
|
r.Use(middleware.AuthMiddleware(jwtSecret))
|
||||||
|
|
||||||
r.Post("/logout", handler.Logout)
|
r.Post("/logout", handler.Logout)
|
||||||
r.Get("/profile", handler.GetProfile)
|
r.Post("/change-password", handler.RequestPasswordReset)
|
||||||
r.Put("/profile", handler.UpdateProfile)
|
|
||||||
r.Post("/change-password", handler.ChangePassword)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"api_yal/internal/logger"
|
"api_yal/internal/logger"
|
||||||
"api_yal/internal/models"
|
"api_yal/internal/models"
|
||||||
"api_yal/internal/repository"
|
"api_yal/internal/repository"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,12 +19,15 @@ import (
|
|||||||
// AuthService интерфейс сервиса аутентификации
|
// AuthService интерфейс сервиса аутентификации
|
||||||
type AuthService interface {
|
type AuthService interface {
|
||||||
Register(req RegisterRequest) (*AuthResponse, error)
|
Register(req RegisterRequest) (*AuthResponse, error)
|
||||||
Login(req LoginRequest) (*AuthResponse, string, error) // Возвращает refresh token отдельно
|
Login(req LoginRequest) (*AuthResponse, string, error)
|
||||||
RefreshToken(refreshToken string) (*RefreshTokenResponse, error)
|
RefreshToken(refreshToken string) (*RefreshTokenResponse, error)
|
||||||
Logout(userID uint) error
|
Logout(userID uint) error
|
||||||
ValidateAccessToken(tokenString string) (*jwt.MapClaims, error)
|
ValidateAccessToken(tokenString string) (*jwt.MapClaims, error)
|
||||||
GetUserFromToken(claims *jwt.MapClaims) (*models.Account, error)
|
GetUserFromToken(claims *jwt.MapClaims) (*models.Account, error)
|
||||||
GenerateNewRefreshToken(userID uint) (string, error)
|
|
||||||
|
// Reset password methods
|
||||||
|
RequestPasswordReset(email string) (string, error) // Возвращает reset token
|
||||||
|
ConfirmPasswordReset(token, newPassword string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// authServiceImpl реализация сервиса аутентификации
|
// authServiceImpl реализация сервиса аутентификации
|
||||||
@@ -31,6 +36,7 @@ type authServiceImpl struct {
|
|||||||
jwtSecret []byte
|
jwtSecret []byte
|
||||||
accessTokenTTL time.Duration
|
accessTokenTTL time.Duration
|
||||||
refreshTokenTTL time.Duration
|
refreshTokenTTL time.Duration
|
||||||
|
resetTokenTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthServiceConfig конфигурация для сервиса аутентификации
|
// AuthServiceConfig конфигурация для сервиса аутентификации
|
||||||
@@ -38,6 +44,7 @@ type AuthServiceConfig struct {
|
|||||||
JWTSecret string
|
JWTSecret string
|
||||||
AccessTokenTTL time.Duration // Рекомендуется 15-30 минут
|
AccessTokenTTL time.Duration // Рекомендуется 15-30 минут
|
||||||
RefreshTokenTTL time.Duration // Рекомендуется 7-30 дней
|
RefreshTokenTTL time.Duration // Рекомендуется 7-30 дней
|
||||||
|
ResetTokenTTL time.Duration // Рекомендуется 1 час
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthService создает новый экземпляр сервиса аутентификации
|
// NewAuthService создает новый экземпляр сервиса аутентификации
|
||||||
@@ -47,43 +54,10 @@ func NewAuthService(accountRepo repository.AccountRepository, config AuthService
|
|||||||
jwtSecret: []byte(config.JWTSecret),
|
jwtSecret: []byte(config.JWTSecret),
|
||||||
accessTokenTTL: config.AccessTokenTTL,
|
accessTokenTTL: config.AccessTokenTTL,
|
||||||
refreshTokenTTL: config.RefreshTokenTTL,
|
refreshTokenTTL: config.RefreshTokenTTL,
|
||||||
|
resetTokenTTL: config.ResetTokenTTL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateNewRefreshToken генерирует новый refresh token для пользователя по ID
|
|
||||||
func (s *authServiceImpl) GenerateNewRefreshToken(userID uint) (string, error) {
|
|
||||||
l := logger.Get()
|
|
||||||
l.Info("Генерация нового refresh token", zap.Uint("userID", userID))
|
|
||||||
|
|
||||||
// Получаем пользователя из базы данных
|
|
||||||
account, err := s.accountRepo.GetByID(userID)
|
|
||||||
if err != nil {
|
|
||||||
l.Error("Пользователь не найден при генерации refresh token",
|
|
||||||
zap.Uint("userID", userID),
|
|
||||||
zap.Error(err))
|
|
||||||
return "", ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, активен ли аккаунт
|
|
||||||
if !account.IsActive {
|
|
||||||
l.Error("Попытка генерации refresh token для деактивированного аккаунта",
|
|
||||||
zap.Uint("userID", userID))
|
|
||||||
return "", errors.New("account is deactivated")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Генерируем новый refresh token
|
|
||||||
refreshToken, err := s.generateRefreshToken(account)
|
|
||||||
if err != nil {
|
|
||||||
l.Error("Ошибка генерации refresh token",
|
|
||||||
zap.Uint("userID", userID),
|
|
||||||
zap.Error(err))
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
l.Info("Refresh token успешно сгенерирован", zap.Uint("userID", userID))
|
|
||||||
return refreshToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register регистрирует нового пользователя
|
// Register регистрирует нового пользователя
|
||||||
func (s *authServiceImpl) Register(req RegisterRequest) (*AuthResponse, error) {
|
func (s *authServiceImpl) Register(req RegisterRequest) (*AuthResponse, error) {
|
||||||
l := logger.Get()
|
l := logger.Get()
|
||||||
@@ -147,7 +121,7 @@ func (s *authServiceImpl) Register(req RegisterRequest) (*AuthResponse, error) {
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login аутентифицирует пользователя и возвращает access и refresh токены
|
// Login аутентифицирует пользователя
|
||||||
func (s *authServiceImpl) Login(req LoginRequest) (*AuthResponse, string, error) {
|
func (s *authServiceImpl) Login(req LoginRequest) (*AuthResponse, string, error) {
|
||||||
l := logger.Get()
|
l := logger.Get()
|
||||||
l.Info("Начало входа пользователя", zap.String("email", req.Email))
|
l.Info("Начало входа пользователя", zap.String("email", req.Email))
|
||||||
@@ -200,6 +174,99 @@ func (s *authServiceImpl) Login(req LoginRequest) (*AuthResponse, string, error)
|
|||||||
return response, refreshToken, nil
|
return response, refreshToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequestPasswordReset запрашивает сброс пароля
|
||||||
|
func (s *authServiceImpl) RequestPasswordReset(email string) (string, error) {
|
||||||
|
l := logger.Get()
|
||||||
|
l.Info("Запрос сброса пароля", zap.String("email", email))
|
||||||
|
|
||||||
|
// Проверяем существование пользователя
|
||||||
|
account, err := s.accountRepo.GetByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("Пользователь не найден", zap.String("email", email), zap.Error(err))
|
||||||
|
return "", ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем reset token
|
||||||
|
resetToken, err := s.generateResetToken(account)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("Ошибка генерации reset token", zap.Error(err))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем reset token в базу данных
|
||||||
|
// Для этого нужно создать модель PasswordReset
|
||||||
|
passwordReset := &models.PasswordReset{
|
||||||
|
AccountID: account.Base.ID,
|
||||||
|
Token: resetToken,
|
||||||
|
ExpiresAt: time.Now().Add(s.resetTokenTTL),
|
||||||
|
Used: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.accountRepo.CreatePasswordReset(passwordReset); err != nil {
|
||||||
|
l.Error("Ошибка сохранения reset token", zap.Error(err))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Info("Reset token создан", zap.String("email", email), zap.String("token", resetToken))
|
||||||
|
return resetToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmPasswordReset подтверждает сброс пароля
|
||||||
|
func (s *authServiceImpl) ConfirmPasswordReset(token, newPassword string) error {
|
||||||
|
l := logger.Get()
|
||||||
|
l.Info("Подтверждение сброса пароля")
|
||||||
|
|
||||||
|
// Находим reset token в базе
|
||||||
|
passwordReset, err := s.accountRepo.GetPasswordResetByToken(token)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("Reset token не найден", zap.Error(err))
|
||||||
|
return ErrResetTokenNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, не истек ли токен
|
||||||
|
if passwordReset.ExpiresAt.Before(time.Now()) {
|
||||||
|
l.Error("Reset token истек")
|
||||||
|
return ErrResetTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, не использован ли токен
|
||||||
|
if passwordReset.Used {
|
||||||
|
l.Error("Reset token уже использован")
|
||||||
|
return ErrResetTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователя
|
||||||
|
account, err := s.accountRepo.GetByID(passwordReset.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("Пользователь не найден", zap.Error(err))
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хешируем новый пароль
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("Ошибка хеширования пароля", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем пароль пользователя
|
||||||
|
account.PasswordHash = string(hashedPassword)
|
||||||
|
if err := s.accountRepo.Update(account); err != nil {
|
||||||
|
l.Error("Ошибка обновления пароля", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Помечаем токен как использованный
|
||||||
|
passwordReset.Used = true
|
||||||
|
if err := s.accountRepo.UpdatePasswordReset(passwordReset); err != nil {
|
||||||
|
l.Error("Ошибка обновления статуса токена", zap.Error(err))
|
||||||
|
// Не возвращаем ошибку, так как пароль уже изменен
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Info("Пароль успешно изменен", zap.Uint("userID", account.Base.ID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshToken обновляет JWT токен по refresh token
|
// RefreshToken обновляет JWT токен по refresh token
|
||||||
func (s *authServiceImpl) RefreshToken(refreshToken string) (*RefreshTokenResponse, error) {
|
func (s *authServiceImpl) RefreshToken(refreshToken string) (*RefreshTokenResponse, error) {
|
||||||
l := logger.Get()
|
l := logger.Get()
|
||||||
@@ -266,7 +333,6 @@ func (s *authServiceImpl) Logout(userID uint) error {
|
|||||||
l := logger.Get()
|
l := logger.Get()
|
||||||
l.Info("Выход пользователя", zap.Uint("userID", userID))
|
l.Info("Выход пользователя", zap.Uint("userID", userID))
|
||||||
// Здесь можно добавить логику инвалидации токенов (например, в Redis)
|
// Здесь можно добавить логику инвалидации токенов (например, в Redis)
|
||||||
// Для базовой реализации достаточно удалить cookie на клиенте
|
|
||||||
l.Info("Выход успешно завершен", zap.Uint("userID", userID))
|
l.Info("Выход успешно завершен", zap.Uint("userID", userID))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -274,7 +340,6 @@ func (s *authServiceImpl) Logout(userID uint) error {
|
|||||||
// ValidateAccessToken валидирует access token
|
// ValidateAccessToken валидирует access token
|
||||||
func (s *authServiceImpl) ValidateAccessToken(tokenString string) (*jwt.MapClaims, error) {
|
func (s *authServiceImpl) ValidateAccessToken(tokenString string) (*jwt.MapClaims, error) {
|
||||||
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
|
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
|
||||||
// Проверяем метод подписи
|
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
}
|
}
|
||||||
@@ -294,7 +359,6 @@ func (s *authServiceImpl) ValidateAccessToken(tokenString string) (*jwt.MapClaim
|
|||||||
return nil, ErrInvalidToken
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем тип токена
|
|
||||||
if tokenType, exists := claims["type"]; !exists || tokenType != "access" {
|
if tokenType, exists := claims["type"]; !exists || tokenType != "access" {
|
||||||
return nil, ErrInvalidToken
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
@@ -360,6 +424,18 @@ func (s *authServiceImpl) generateRefreshToken(account *models.Account) (string,
|
|||||||
return tokenString, nil
|
return tokenString, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateResetToken генерирует reset token
|
||||||
|
func (s *authServiceImpl) generateResetToken(account *models.Account) (string, error) {
|
||||||
|
// Генерируем случайный токен
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
token := base64.URLEncoding.EncodeToString(bytes)
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateRefreshToken валидирует refresh token
|
// validateRefreshToken валидирует refresh token
|
||||||
func (s *authServiceImpl) validateRefreshToken(tokenString string) (*jwt.MapClaims, error) {
|
func (s *authServiceImpl) validateRefreshToken(tokenString string) (*jwt.MapClaims, error) {
|
||||||
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
|
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
|
||||||
@@ -382,12 +458,10 @@ func (s *authServiceImpl) validateRefreshToken(tokenString string) (*jwt.MapClai
|
|||||||
return nil, ErrInvalidToken
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем тип токена
|
|
||||||
if tokenType, exists := claims["type"]; !exists || tokenType != "refresh" {
|
if tokenType, exists := claims["type"]; !exists || tokenType != "refresh" {
|
||||||
return nil, ErrInvalidToken
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем время истечения
|
|
||||||
exp, ok := claims["exp"].(float64)
|
exp, ok := claims["exp"].(float64)
|
||||||
if ok && int64(exp) < time.Now().Unix() {
|
if ok && int64(exp) < time.Now().Unix() {
|
||||||
return nil, ErrTokenExpired
|
return nil, ErrTokenExpired
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// models/password_reset.go
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordReset модель для хранения токенов сброса пароля
|
||||||
|
type PasswordReset struct {
|
||||||
|
Base
|
||||||
|
AccountID uint `gorm:"not null;index" json:"account_id"`
|
||||||
|
Token string `gorm:"type:varchar(255);uniqueIndex;not null" json:"token"`
|
||||||
|
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
||||||
|
Used bool `gorm:"default:false" json:"used"`
|
||||||
|
|
||||||
|
// Связи
|
||||||
|
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName возвращает имя таблицы
|
||||||
|
func (PasswordReset) TableName() string {
|
||||||
|
return "password_resets"
|
||||||
|
}
|
||||||
@@ -33,4 +33,13 @@ type AccountRepository interface {
|
|||||||
|
|
||||||
// GetObjects возвращает все объекты, принадлежащие аккаунту
|
// GetObjects возвращает все объекты, принадлежащие аккаунту
|
||||||
GetObjects(accountID uint) ([]models.Object, error)
|
GetObjects(accountID uint) ([]models.Object, error)
|
||||||
|
|
||||||
|
// CreatePasswordReset создаёт новую запись о сбросе пароля
|
||||||
|
CreatePasswordReset(reset *models.PasswordReset) error
|
||||||
|
|
||||||
|
// GetPasswordResetByToken находит запись о сбросе пароля по токену
|
||||||
|
GetPasswordResetByToken(token string) (*models.PasswordReset, error)
|
||||||
|
|
||||||
|
// UpdatePasswordReset обновляет запись о сбросе пароля
|
||||||
|
UpdatePasswordReset(reset *models.PasswordReset) error
|
||||||
}
|
}
|
||||||
@@ -86,3 +86,23 @@ func (r *accountRepositoryImpl) GetObjects(accountID uint) ([]models.Object, err
|
|||||||
}
|
}
|
||||||
return objects, nil
|
return objects, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreatePasswordReset создаёт новую запись о сбросе пароля
|
||||||
|
func (r *accountRepositoryImpl) CreatePasswordReset(reset *models.PasswordReset) error {
|
||||||
|
return r.db.Create(reset).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPasswordResetByToken находит запись о сбросе пароля по токену
|
||||||
|
func (r *accountRepositoryImpl) GetPasswordResetByToken(token string) (*models.PasswordReset, error) {
|
||||||
|
var reset models.PasswordReset
|
||||||
|
err := r.db.Where("token = ?", token).First(&reset).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &reset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePasswordReset обновляет запись о сбросе пароля
|
||||||
|
func (r *accountRepositoryImpl) UpdatePasswordReset(reset *models.PasswordReset) error {
|
||||||
|
return r.db.Save(reset).Error
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user