feat: Implement Authentication Handlers (Issue #3)

- Create AuthService with login, refresh token, and user management
- Implement Login with password verification
- Implement RefreshToken for token renewal
- Implement GetCurrentUser endpoint
- Add ChangePassword functionality
- Add CreateUser for admin user creation
- Create AuthHandler with HTTP endpoints
- Add comprehensive validation and error handling
- Add google/uuid dependency to go.mod

Closes #3

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-02-05 00:48:55 +03:00
parent bdf19fdb62
commit d0624a2bc2
3 changed files with 416 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.18.0
github.com/joho/godotenv v1.5.1

View File

@@ -0,0 +1,184 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yourusername/victorialogs-manager/internal/services"
)
// AuthHandler handles authentication endpoints
type AuthHandler struct {
authService *services.AuthService
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
// Login handles user login
// @Summary User login
// @Description Authenticate user and return JWT tokens
// @Tags auth
// @Accept json
// @Produce json
// @Param request body services.LoginRequest true "Login credentials"
// @Success 200 {object} services.LoginResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Router /api/v1/auth/login [post]
func (h *AuthHandler) Login(c *gin.Context) {
var req services.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "invalid request",
Message: err.Error(),
})
return
}
// Validate input
if req.Username == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "validation error",
Message: "username and password are required",
})
return
}
resp, err := h.authService.Login(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse{
Error: "authentication failed",
Message: err.Error(),
})
return
}
c.JSON(http.StatusOK, resp)
}
// RefreshToken handles token refresh
// @Summary Refresh access token
// @Description Get new access and refresh tokens using refresh token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body services.RefreshTokenRequest true "Refresh token"
// @Success 200 {object} services.RefreshTokenResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Router /api/v1/auth/refresh [post]
func (h *AuthHandler) RefreshToken(c *gin.Context) {
var req services.RefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "invalid request",
Message: err.Error(),
})
return
}
resp, err := h.authService.RefreshToken(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse{
Error: "refresh failed",
Message: err.Error(),
})
return
}
c.JSON(http.StatusOK, resp)
}
// GetMe returns the current user
// @Summary Get current user
// @Description Get current authenticated user information
// @Tags auth
// @Produce json
// @Success 200 {object} models.User
// @Failure 401 {object} ErrorResponse
// @Security BearerAuth
// @Router /api/v1/auth/me [get]
func (h *AuthHandler) GetMe(c *gin.Context) {
// Get user ID from context (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, ErrorResponse{
Error: "unauthorized",
Message: "user not authenticated",
})
return
}
user, err := h.authService.GetCurrentUser(c.Request.Context(), userID.(string))
if err != nil {
c.JSON(http.StatusNotFound, ErrorResponse{
Error: "user not found",
Message: err.Error(),
})
return
}
c.JSON(http.StatusOK, user)
}
// ChangePassword handles password change
// @Summary Change password
// @Description Change current user's password
// @Tags auth
// @Accept json
// @Produce json
// @Param request body services.ChangePasswordRequest true "Password change request"
// @Success 200 {object} SuccessResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Security BearerAuth
// @Router /api/v1/auth/change-password [post]
func (h *AuthHandler) ChangePassword(c *gin.Context) {
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, ErrorResponse{
Error: "unauthorized",
Message: "user not authenticated",
})
return
}
var req services.ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "invalid request",
Message: err.Error(),
})
return
}
err := h.authService.ChangePassword(c.Request.Context(), userID.(string), &req)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "password change failed",
Message: err.Error(),
})
return
}
c.JSON(http.StatusOK, SuccessResponse{
Message: "password changed successfully",
})
}
// ErrorResponse represents an error response
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
// SuccessResponse represents a success response
type SuccessResponse struct {
Message string `json:"message"`
}

View File

@@ -0,0 +1,231 @@
package services
import (
"context"
"fmt"
"golang.org/x/crypto/bcrypt"
"github.com/google/uuid"
"github.com/yourusername/victorialogs-manager/internal/models"
"github.com/yourusername/victorialogs-manager/internal/repository"
"github.com/yourusername/victorialogs-manager/internal/utils"
)
// AuthService handles authentication business logic
type AuthService struct {
userRepo *repository.UserRepository
jwtManager *utils.JWTManager
}
// NewAuthService creates a new auth service
func NewAuthService(userRepo *repository.UserRepository, jwtManager *utils.JWTManager) *AuthService {
return &AuthService{
userRepo: userRepo,
jwtManager: jwtManager,
}
}
// LoginRequest represents a login request
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// LoginResponse represents a login response
type LoginResponse struct{
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
User *models.User `json:"user"`
}
// Login authenticates a user and returns JWT tokens
func (s *AuthService) Login(ctx context.Context, req *LoginRequest) (*LoginResponse, error) {
// Get user by username
user, err := s.userRepo.GetByUsername(ctx, req.Username)
if err != nil {
return nil, fmt.Errorf("invalid credentials")
}
// Check if user is active
if !user.IsActive {
return nil, fmt.Errorf("user account is disabled")
}
// Verify password
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
if err != nil {
return nil, fmt.Errorf("invalid credentials")
}
// Generate access token
accessToken, err := s.jwtManager.GenerateAccessToken(user)
if err != nil {
return nil, fmt.Errorf("failed to generate access token: %w", err)
}
// Generate refresh token
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID)
if err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
// Clear password hash from response
user.PasswordHash = ""
return &LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: user,
}, nil
}
// RefreshTokenRequest represents a refresh token request
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
// RefreshTokenResponse represents a refresh token response
type RefreshTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
// RefreshToken generates new tokens from a refresh token
func (s *AuthService) RefreshToken(ctx context.Context, req *RefreshTokenRequest) (*RefreshTokenResponse, error) {
// Validate refresh token
userID, err := s.jwtManager.ValidateRefreshToken(req.RefreshToken)
if err != nil {
return nil, fmt.Errorf("invalid refresh token: %w", err)
}
// Get user
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("user not found")
}
// Check if user is active
if !user.IsActive {
return nil, fmt.Errorf("user account is disabled")
}
// Generate new access token
accessToken, err := s.jwtManager.GenerateAccessToken(user)
if err != nil {
return nil, fmt.Errorf("failed to generate access token: %w", err)
}
// Generate new refresh token
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID)
if err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
return &RefreshTokenResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
}, nil
}
// GetCurrentUser returns the current user from claims
func (s *AuthService) GetCurrentUser(ctx context.Context, userID string) (*models.User, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("user not found")
}
// Clear password hash
user.PasswordHash = ""
return user, nil
}
// CreateUserRequest represents a user creation request
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Role models.Role `json:"role" binding:"required"`
}
// CreateUser creates a new user (admin only)
func (s *AuthService) CreateUser(ctx context.Context, req *CreateUserRequest) (*models.User, error) {
// Check if username exists
exists, err := s.userRepo.UsernameExists(ctx, req.Username)
if err != nil {
return nil, fmt.Errorf("failed to check username: %w", err)
}
if exists {
return nil, fmt.Errorf("username already exists")
}
// Check if email exists
exists, err = s.userRepo.EmailExists(ctx, req.Email)
if err != nil {
return nil, fmt.Errorf("failed to check email: %w", err)
}
if exists {
return nil, fmt.Errorf("email already exists")
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Create user
user := &models.User{
ID: uuid.New().String(),
Username: req.Username,
Email: req.Email,
PasswordHash: string(hashedPassword),
Role: req.Role,
IsActive: true,
}
err = s.userRepo.Create(ctx, user)
if err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
// Clear password hash
user.PasswordHash = ""
return user, nil
}
// ChangePasswordRequest represents a password change request
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
// ChangePassword changes a user's password
func (s *AuthService) ChangePassword(ctx context.Context, userID string, req *ChangePasswordRequest) error {
// Get user
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return fmt.Errorf("user not found")
}
// Verify old password
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword))
if err != nil {
return fmt.Errorf("invalid old password")
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// Update password
err = s.userRepo.UpdatePassword(ctx, userID, string(hashedPassword))
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
return nil
}