Добавлен основные классы для сервиса авторизаци

This commit is contained in:
Ганеев Артем
2025-10-28 20:49:54 +03:00
parent def3552a67
commit 736b8031f8
26 changed files with 904 additions and 40 deletions

View File

@@ -1,37 +1,49 @@
package internal
type RegistrationUser struct {
Username string `json:"username"`
Password string `json:"password"`
}
const (
Student UserRole = iota
Teacher
Admin
import (
"errors"
"fmt"
)
type User struct {
Username string
Password string
UserRole UserRole
Id int `json:"-" db:"id"`
Name string `json:"name" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
UserRole string `json:"-" db:"role"`
}
func (user *User) ChangeRole(userRole UserRole) {
user.UserRole = userRole
type AuthUser struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type UserRole int
type RefreshTokenRequest struct {
RefreshToken string `json:"refreshToken" binding:"required"`
}
func (role UserRole) String() string {
switch role {
case Student:
return "student"
case Teacher:
return "teacher"
case Admin:
return "admin"
default:
return "unknown"
type UserRole string
const (
Student UserRole = "student"
Teacher UserRole = "teacher"
Admin UserRole = "admin"
)
func (userrole UserRole) ToString() string {
return string(userrole)
}
var ErrInvalidRole = errors.New("invalid role")
func FromString(in string) (UserRole, error) {
switch in {
case Student.ToString():
return Student, nil
case Teacher.ToString():
return Teacher, nil
case Admin.ToString():
return Admin, nil
}
return Student, fmt.Errorf("%q is not a valid role: %w", in, ErrInvalidRole)
}

42
internal/config/config.go Normal file
View File

@@ -0,0 +1,42 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Token TokenConfig `yaml:"token" json:"token"`
Server ServerConfig `yaml:"server" json:"server"`
DB DatabaseConfig `yaml:"db" json:"db"`
}
type ServerConfig struct {
Port string `yaml:"port" json:"port"`
}
type DatabaseConfig struct {
Username string `yaml:"username" json:"username"`
Host string `yaml:"host" json:"host"`
Port string `yaml:"port" json:"port"`
Sslmode string `yaml:"sslmode" json:"sslmode"`
DBname string `yaml:"dbname" json:"dbname"`
}
func LoadConfig(absolutePath string) (*Config, error) {
config := &Config{}
file, err := os.Open(absolutePath)
if err != nil {
return nil, err
}
defer file.Close()
decoder := yaml.NewDecoder(file)
if err := decoder.Decode(config); err != nil {
return nil, err
}
return config, nil
}

22
internal/config/token.go Normal file
View File

@@ -0,0 +1,22 @@
package config
import "time"
type TokenConfig struct {
AccessToken TokenSettings `yaml:"accessToken" json:"accessToken"`
RefreshToken TokenSettings `yaml:"refreshToken" json:"refreshToken"`
}
type TokenSettings struct {
TTLInMinutes int `yaml:"TTL-in-min" json:"TTL-in-min"`
SecretWord string `yaml:"secretWord" json:"secretWord"`
}
// Методы для удобства
func (t *TokenSettings) GetTTL() time.Duration {
return time.Duration(t.TTLInMinutes) * time.Minute
}
func (t *TokenSettings) GetSecretBytes() []byte {
return []byte(t.SecretWord)
}

View File

@@ -1,13 +1,65 @@
package handler
import (
"authorization/internal"
"net/http"
"github.com/gin-gonic/gin"
)
func (h *Handler) signUp(c *gin.Context) {
var input internal.User
if err := c.BindJSON(&input); err != nil {
newErrorResponse(c, http.StatusBadRequest, err.Error())
return
}
id, err := h.services.Authorization.CreateUser(input)
if err != nil {
newErrorResponse(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, map[string]interface{}{
"id": id,
})
}
func (h *Handler) signIn(c *gin.Context) {
var input internal.AuthUser
if err := c.BindJSON(&input); err != nil {
newErrorResponse(c, http.StatusBadRequest, err.Error())
return
}
accesstoken, refreshToken, err := h.services.Authorization.GenerateToken(input.Username, input.Password)
if err != nil {
newErrorResponse(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, map[string]interface{}{
"accessToken": accesstoken,
"refreshToken": refreshToken,
})
}
func (h *Handler) refresh(c *gin.Context) {
var input internal.RefreshTokenRequest
if err := c.BindJSON(&input); err != nil {
newErrorResponse(c, http.StatusBadRequest, err.Error())
return
}
accessToken, refreshToken, err := h.services.Authorization.RefreshToken(input.RefreshToken)
if err != nil {
newErrorResponse(c, http.StatusUnauthorized, err.Error())
return
}
c.JSON(http.StatusOK, map[string]interface{}{
"accessToken": accessToken,
"refreshToken": refreshToken,
})
}

View File

@@ -1,17 +1,40 @@
package handler
import "github.com/gin-gonic/gin"
import (
"authorization/internal/service"
"github.com/gin-gonic/gin"
)
type Handler struct {
services *service.Service
}
func NewHandler(services *service.Service) *Handler {
return &Handler{
services: services,
}
}
func (h *Handler) InitRoutes() *gin.Engine {
router := gin.New()
auth := router.Group("/auth")
serviceRouter := router.Group("/auth-service")
{
auth.POST("/sign-up", h.signUp)
auth.POST("/sign-in", h.signIn)
auth := serviceRouter.Group("/auth")
{
auth.POST("/sign-up", h.signUp)
auth.POST("/sign-in", h.signIn)
auth.POST("/refresh", h.refresh)
}
api := router.Group("/api")
{
users := api.Group("/users", h.checkAdminIdentity)
{
users.POST("/:username", h.changeUserRole)
}
}
}
return router
}

View File

@@ -0,0 +1,42 @@
package handler
import (
"authorization/internal"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
const (
authorizationHeader = "Authorization"
roleKey = "user_role"
)
func (h *Handler) checkAdminIdentity(c *gin.Context) {
header := c.GetHeader(authorizationHeader)
if header == "" {
newErrorResponse(c, http.StatusUnauthorized, "Пустой header авторизации")
return
}
headerParts := strings.Split(header, " ")
if len(headerParts) != 2 {
newErrorResponse(c, http.StatusUnauthorized, "Невалидный токен JWT")
return
}
userRole, err := h.services.ParseToken(headerParts[1])
if userRole != string(internal.Admin) {
newErrorResponse(c, http.StatusUnauthorized, "Недостаточно прав для выполнения запроса")
return
}
if err != nil {
newErrorResponse(c, http.StatusUnauthorized, "Ошибка при извлечении claims")
return
}
c.Set(roleKey, userRole)
}

View File

@@ -0,0 +1,15 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
type error struct {
Message string `json:"message"`
}
func newErrorResponse(c *gin.Context, statusCode int, message string) {
logrus.Error(message)
c.AbortWithStatusJSON(statusCode, error{Message: message})
}

36
internal/handler/users.go Normal file
View File

@@ -0,0 +1,36 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
type ChangeUserRoleRequest struct {
Role string `json:"role"`
}
func (h *Handler) changeUserRole(c *gin.Context) {
var input ChangeUserRoleRequest
if err := c.BindJSON(&input); err != nil {
newErrorResponse(c, http.StatusBadRequest, err.Error())
return
}
username := c.Param("username")
if username == "" {
newErrorResponse(c, http.StatusBadRequest, "Ошибка в строке запроса")
return
}
role, err := h.services.ChangeUserRole(username, input.Role)
if err != nil {
newErrorResponse(c, http.StatusBadRequest, err.Error())
return
}
c.JSON(http.StatusOK, map[string]interface{}{
"newRole": role,
})
}

View File

@@ -0,0 +1,35 @@
package repository
import (
"database/sql"
"fmt"
_ "github.com/lib/pq"
)
const (
usersTable = "users"
)
type Config struct {
Host string
Port string
Username string
Password string
DBName string
SSLMode string
}
func NewPostgresDB(cfg Config) (*sql.DB, error) {
db, err := sql.Open("postgres",
fmt.Sprintf("user=%s password=%s host=%s dbname=%s sslmode=%s",
cfg.Username, cfg.Password, cfg.Host, cfg.DBName, cfg.SSLMode))
if err != nil {
return nil, err
}
err = db.Ping()
if err != nil {
return nil, err
}
return db, nil
}

View File

@@ -0,0 +1,22 @@
package repository
import (
"authorization/internal"
"database/sql"
)
type UserResository interface {
CreateUser(user internal.User) (int, error)
GetUser(username, password string) (internal.User, error)
UpdateUserRole(username string, userrole internal.UserRole) (string, error)
}
type Repository struct {
UserResository
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{
UserResository: NewUserPostgres(db),
}
}

View File

@@ -0,0 +1,44 @@
package repository
import (
"authorization/internal"
"database/sql"
"fmt"
)
type UserPostgres struct {
db *sql.DB
}
func NewUserPostgres(db *sql.DB) *UserPostgres {
return &UserPostgres{db: db}
}
func (r *UserPostgres) CreateUser(user internal.User) (int, error) {
var id int
query := fmt.Sprintf("INSERT INTO %s(name,username,password_hash,role) values ($1,$2,$3,$4) RETURNING id", usersTable)
row := r.db.QueryRow(query, user.Name, user.Username, user.Password, internal.Student)
if err := row.Scan(&id); err != nil {
return 0, err
}
return id, nil
}
func (r *UserPostgres) GetUser(username, password string) (internal.User, error) {
var user internal.User
query := fmt.Sprintf("SELECT * from %s where username = $1 AND password_hash=$2", usersTable)
row := r.db.QueryRow(query, username, password)
err := row.Scan(&user.Id, &user.Name, &user.Username, &user.Password, &user.UserRole)
return user, err
}
func (r *UserPostgres) UpdateUserRole(username string, userrole internal.UserRole) (string, error) {
query := fmt.Sprintf("UPDATE %s SET role = $1 WHERE username = $2 RETURNING role", usersTable)
var newRole string
err := r.db.QueryRow(query, userrole, username).Scan(&newRole)
if err != nil {
return "", fmt.Errorf("failed to update user role: %v", err)
}
return newRole, nil
}

142
internal/service/auth.go Normal file
View File

@@ -0,0 +1,142 @@
package service
import (
"authorization/internal"
"authorization/internal/config"
"crypto/sha1"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt"
)
const (
refresh = "refresh"
access = "access"
)
type tokenClaims struct {
jwt.StandardClaims
UserId int `json:"user_id"`
UserRole string `json:"user_role"`
TokenType string `json:"token_type"`
}
type AuthService struct {
userService UserService
tokenConfigs config.TokenConfig
}
func newAuthService(userService UserService) *AuthService {
return &AuthService{userService: userService}
}
func (s *AuthService) CreateUser(user internal.User) (int, error) {
user.Password = s.generatePasswordHash(user.Password)
return s.userService.CreateUser(user)
}
func (s *AuthService) GenerateToken(username string, password string) (string, string, error) {
user, err := s.userService.GetUser(username, s.generatePasswordHash(password))
if err != nil {
return "", "", err
}
accessTokenClaims := s.generateClaims(user.Id, user.UserRole, access)
refreshTokenClaims := s.generateClaims(user.Id, user.UserRole, refresh)
accessToken, err := accessTokenClaims.SignedString([]byte(s.tokenConfigs.AccessToken.GetSecretBytes()))
if err != nil {
return "", "", err
}
refreshToken, err := refreshTokenClaims.SignedString([]byte(s.tokenConfigs.RefreshToken.GetSecretBytes()))
if err != nil {
return "", "", err
}
return accessToken, refreshToken, nil
}
func (s *AuthService) ChangeUserRole(username string, userrole string) (string, error) {
user, err := s.userService.ChangeUserRole(username, userrole)
if err != nil {
return "", err
}
return user, nil
}
func (s *AuthService) generateClaims(userId int, userRole string, tokenType string) *jwt.Token {
tokenTTL := s.tokenConfigs.RefreshToken.GetTTL()
if tokenType == access {
tokenTTL = s.tokenConfigs.AccessToken.GetTTL()
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, &tokenClaims{
jwt.StandardClaims{
ExpiresAt: time.Now().Add(tokenTTL).Unix(),
IssuedAt: time.Now().Unix(),
},
userId,
userRole,
tokenType,
})
}
func (s *AuthService) generatePasswordHash(password string) string {
hash := sha1.New()
hash.Write([]byte(password))
return fmt.Sprintf("%x", hash)
}
func (s *AuthService) ParseToken(accessToken string) (string, error) {
claims, err := s.parseTokenWithSecret(accessToken, s.tokenConfigs.AccessToken.GetSecretBytes())
if err != nil {
return "", err
}
return claims.UserRole, nil
}
func (s *AuthService) RefreshToken(refreshToken string) (string, string, error) {
// Парсим refresh token
claims, err := s.parseTokenWithSecret(refreshToken, s.tokenConfigs.RefreshToken.GetSecretBytes())
if err != nil {
return "", "", errors.New("invalid refresh token")
}
// Проверяем, что это именно refresh token
if claims.TokenType != refresh {
return "", "", errors.New("token is not a refresh token")
}
// Генерируем новую пару токенов
newAccessTokenClaims := s.generateClaims(claims.UserId, claims.UserRole, access)
newRefreshTokenClaims := s.generateClaims(claims.UserId, claims.UserRole, refresh)
newAccessToken, err := newAccessTokenClaims.SignedString([]byte(s.tokenConfigs.AccessToken.GetSecretBytes()))
if err != nil {
return "", "", err
}
newRefreshToken, err := newRefreshTokenClaims.SignedString([]byte(s.tokenConfigs.RefreshToken.GetSecretBytes()))
if err != nil {
return "", "", err
}
return newAccessToken, newRefreshToken, nil
}
// parseTokenWithSecret - общий метод для парсинга токена с заданным секретным ключом
func (s *AuthService) parseTokenWithSecret(tokenString string, secret []byte) (*tokenClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &tokenClaims{}, func(t *jwt.Token) (interface{}, error) {
return secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*tokenClaims)
if !ok {
return nil, errors.New("invalid token claims")
}
return claims, nil
}

View File

@@ -0,0 +1,117 @@
package service
import (
"authorization/internal/config"
"testing"
"time"
"github.com/golang-jwt/jwt"
"github.com/stretchr/testify/assert"
)
func TestAuthService_ParseToken(t *testing.T) {
// Настройка тестового сервиса
service := &AuthService{
tokenConfigs: config.TokenConfig{
AccessToken: config.TokenSettings{
TTLInMinutes: 15,
SecretWord: "test-secret-key",
},
},
}
t.Run("Valid token", func(t *testing.T) {
// Создаем валидный токен
claims := &tokenClaims{
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(15 * time.Minute).Unix(),
IssuedAt: time.Now().Unix(),
},
UserId: 1,
UserRole: "admin",
TokenType: "access",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte("test-secret-key"))
assert.NoError(t, err)
// Парсим токен
role, err := service.ParseToken(tokenString)
// Проверяем результат
assert.NoError(t, err)
assert.Equal(t, "admin", role)
})
t.Run("Expired token", func(t *testing.T) {
// Создаем истекший токен (истек 1 час назад)
claims := &tokenClaims{
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(),
IssuedAt: time.Now().Add(-2 * time.Hour).Unix(),
},
UserId: 1,
UserRole: "admin",
TokenType: "access",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte("test-secret-key"))
assert.NoError(t, err)
// Парсим истекший токен
role, err := service.ParseToken(tokenString)
// Проверяем, что получили ошибку
assert.Error(t, err)
assert.Empty(t, role)
// Проверяем, что это именно ошибка истечения срока
if ve, ok := err.(*jwt.ValidationError); ok {
assert.True(t, ve.Errors&jwt.ValidationErrorExpired != 0, "Expected token expired error")
}
})
t.Run("Invalid signature", func(t *testing.T) {
// Создаем токен с другим секретным ключом
claims := &tokenClaims{
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(15 * time.Minute).Unix(),
IssuedAt: time.Now().Unix(),
},
UserId: 1,
UserRole: "admin",
TokenType: "access",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte("wrong-secret-key"))
assert.NoError(t, err)
// Пытаемся парсить токен с неправильной подписью
role, err := service.ParseToken(tokenString)
// Проверяем, что получили ошибку
assert.Error(t, err)
assert.Empty(t, role)
})
t.Run("Malformed token", func(t *testing.T) {
// Пытаемся парсить невалидный токен
role, err := service.ParseToken("invalid.token.string")
// Проверяем, что получили ошибку
assert.Error(t, err)
assert.Empty(t, role)
})
t.Run("Empty token", func(t *testing.T) {
// Пытаемся парсить пустой токен
role, err := service.ParseToken("")
// Проверяем, что получили ошибку
assert.Error(t, err)
assert.Empty(t, role)
})
}

View File

@@ -0,0 +1,29 @@
package service
import (
"authorization/internal"
"authorization/internal/repository"
)
type Authorization interface {
CreateUser(internal.User) (int, error)
GenerateToken(username string, password string) (accessToken string, refreshToken string, err error)
ParseToken(token string) (string, error)
RefreshToken(refreshToken string) (accessToken string, newRefreshToken string, err error)
ChangeUserRole(username string, newRole string) (string, error)
}
type Service struct {
Authorization
}
func NewServices(repository *repository.Repository) *Service {
userService := newUserService(repository)
authService := newAuthService(userService)
return &Service{
Authorization: authService,
}
}

44
internal/service/user.go Normal file
View File

@@ -0,0 +1,44 @@
package service
import (
"authorization/internal"
"authorization/internal/repository"
)
type UserService interface {
CreateUser(internal.User) (int, error)
ChangeUserRole(username string, Role string) (string, error)
GetUser(username string, hashedPassword string) (*internal.User, error)
}
type UserServiceImpl struct {
repo repository.UserResository
}
func newUserService(repo repository.UserResository) *UserServiceImpl {
return &UserServiceImpl{repo: repo}
}
func (s *UserServiceImpl) CreateUser(user internal.User) (int, error) {
return s.repo.CreateUser(user)
}
func (s *UserServiceImpl) ChangeUserRole(username string, userRole string) (string, error) {
newRole, err := internal.FromString(userRole)
if err != nil {
return "", err
}
user, err := s.repo.UpdateUserRole(username, newRole)
if err != nil {
return "", err
}
return user, nil
}
func (s *UserServiceImpl) GetUser(username string, hashedPassword string) (*internal.User, error) {
user, err := s.repo.GetUser(username, hashedPassword)
if err != nil {
return nil, err
}
return &user, nil
}