Full auth handlers, refactored swagger and some other changes

This commit is contained in:
2026-03-18 20:51:30 +05:00
parent 91312d5797
commit cf2c133ba6
11 changed files with 279 additions and 91 deletions

3
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/golang-cz/devslog v0.0.15
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gosimple/slug v1.15.0
github.com/huandu/go-sqlbuilder v1.39.1
github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1
@@ -20,6 +21,7 @@ require (
github.com/redis/go-redis/v9 v9.18.0
github.com/rs/xid v1.6.0
github.com/samber/slog-chi v1.19.0
golang.org/x/crypto v0.48.0
)
require (
@@ -27,6 +29,7 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/huandu/go-clone v1.7.3 // indirect
github.com/huandu/xstrings v1.4.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect

6
go.sum
View File

@@ -27,6 +27,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U=
github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs=
github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs=
@@ -102,6 +106,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=

View File

@@ -259,8 +259,6 @@ paths:
operationId: authLogout
summary: Выход из системы
tags: [Auth]
security:
- BearerAuth: []
responses:
"204":
description: Успешный выход
@@ -272,8 +270,6 @@ paths:
operationId: getMe
summary: Получить текущего пользователя
tags: [Me]
security:
- BearerAuth: []
responses:
"200":
description: Данные пользователя
@@ -288,8 +284,6 @@ paths:
operationId: updateMe
summary: Обновить данные текущего пользователя
tags: [Me]
security:
- BearerAuth: []
requestBody:
required: true
content:
@@ -320,8 +314,6 @@ paths:
operationId: deleteMe
summary: Удалить аккаунт
tags: [Me]
security:
- BearerAuth: []
requestBody:
required: true
content:

View File

@@ -4,7 +4,6 @@
package api
import (
"context"
"fmt"
"net/http"
@@ -12,10 +11,6 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
)
const (
BearerAuthScopes = "BearerAuth.Scopes"
)
// ApiResponse defines model for ApiResponse.
type ApiResponse struct {
Data *map[string]interface{} `json:"data,omitempty"`
@@ -180,12 +175,6 @@ func (siw *ServerInterfaceWrapper) AuthLogin(w http.ResponseWriter, r *http.Requ
// AuthLogout operation middleware
func (siw *ServerInterfaceWrapper) AuthLogout(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.AuthLogout(w, r)
}))
@@ -228,12 +217,6 @@ func (siw *ServerInterfaceWrapper) AuthRegister(w http.ResponseWriter, r *http.R
// DeleteMe operation middleware
func (siw *ServerInterfaceWrapper) DeleteMe(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.DeleteMe(w, r)
}))
@@ -248,12 +231,6 @@ func (siw *ServerInterfaceWrapper) DeleteMe(w http.ResponseWriter, r *http.Reque
// GetMe operation middleware
func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetMe(w, r)
}))
@@ -268,12 +245,6 @@ func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request)
// UpdateMe operation middleware
func (siw *ServerInterfaceWrapper) UpdateMe(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.UpdateMe(w, r)
}))

View File

@@ -62,6 +62,8 @@ func (s *Server) Run() error {
MaxAge: 300,
}))
r.Use(s.MiddlewareRequestID)
r.Use(slogchi.NewWithConfig(slog.Default(), slogchi.Config{
DefaultLevel: slog.LevelInfo,
ClientErrorLevel: slog.LevelWarn, // 400499 → Warn
@@ -73,7 +75,6 @@ func (s *Server) Run() error {
}))
r.Use(s.AuthMiddleware)
r.Use(s.MiddlewareRequestID)
h := api.HandlerFromMux(s, r)
@@ -120,14 +121,10 @@ func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
claims, err := s.validateAccessToken(r.Context(), tokenStr)
if err != nil {
slog.WarnContext(r.Context(), "Токен не прошел валидацию", slog.String("Token", tokenStr), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusUnauthorized, "токен не прошёл валидацию", "error")
return
}
key := "access_hash:" + tokenStr
if _, err := s.Redis.Get(r.Context(), key).Result(); err == redis.Nil {
s.JSON(w, r, http.StatusUnauthorized, "token revoked or expired", "error")
}
ctx := context.WithValue(r.Context(), "user", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
@@ -208,7 +205,7 @@ func (s *Server) validateAccessToken(ctx context.Context, tokenStr string) (*Cla
return nil, errors.New("only HS256 allowed")
}
redisKey := "access_hash:" + tokenStr
redisKey := "access_token:" + tokenStr
if _, err := s.Redis.Get(ctx, redisKey).Result(); err != nil {
slog.ErrorContext(ctx, "redis error during token validation", "error", err.Error())
return nil, fmt.Errorf("redis error: %w", err)

View File

@@ -1,10 +1,23 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/anxi0uz/logiflow/internal/api"
"github.com/anxi0uz/logiflow/internal/models"
storage "github.com/anxi0uz/logiflow/pkg"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/gosimple/slug"
"github.com/huandu/go-sqlbuilder"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
type Claims struct {
@@ -16,11 +29,247 @@ type Claims struct {
}
func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req api.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.JSON(w, r, http.StatusBadRequest, "Ошибка при получении данных", "error")
return
}
user, err := storage.GetOne[models.User](ctx, s.DB, "users", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.Equal("email", req.Email))
})
if err != nil {
slog.WarnContext(ctx, "user with that email not found in db", slog.String("Email", string(req.Email)), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusBadRequest, "Пользователь с таким Email не найден", "error")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
slog.WarnContext(ctx, "Failed login to account with", slog.String("email:", string(req.Email)), slog.String("password from request", req.Password))
s.JSON(w, r, http.StatusBadRequest, "Неверный пароль", "error")
return
}
s.issueTokens(w, r, user)
}
func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
cookie, err := r.Cookie("refresh_token")
if err != nil {
s.JSON(w, r, http.StatusUnauthorized, "Missing refresh token", "error")
return
}
refreshStr := cookie.Value
if refreshStr == "" {
s.JSON(w, r, http.StatusUnauthorized, "Empty refresh token", "error")
return
}
refreshKey := "refresh_token:" + refreshStr
if err := s.Redis.Del(ctx, refreshKey).Err(); err != nil {
slog.ErrorContext(ctx, "Error while removing refresh token from redis", slog.String("token", refreshStr), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusUnauthorized, "Internal server error", "error")
return
}
tokenStr := r.Header.Get("Authorization")
if tokenStr == "" {
s.JSON(w, r, http.StatusUnauthorized, "missing token", "error")
return
}
tokenKey := "access_token:" + tokenStr
if err := s.Redis.Del(ctx, tokenKey).Err(); err != nil {
slog.ErrorContext(ctx, "Error while removing access token from redis", slog.String("token", tokenStr), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusUnauthorized, "Internal server error", "error")
return
}
}
func (s *Server) AuthRefresh(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
cookie, err := r.Cookie("refresh_token")
if err != nil {
s.JSON(w, r, http.StatusUnauthorized, "Missing refresh token", "error")
return
}
refreshStr := cookie.Value
if refreshStr == "" {
s.JSON(w, r, http.StatusUnauthorized, "Empty refresh token", "error")
return
}
key := "refresh_token:" + refreshStr
if _, err := s.Redis.Get(ctx, key).Result(); err == redis.Nil {
s.JSON(w, r, http.StatusUnauthorized, "No active refresh token", "error")
return
}
claimsValue := ctx.Value("user")
claims, ok := claimsValue.(*Claims)
if !ok {
slog.ErrorContext(ctx, "Error parsing claims", slog.Any("claims", claims))
s.JSON(w, r, http.StatusInternalServerError, nil, "internal server error")
return
}
userID := claims.ID
if userID == uuid.Nil {
s.JSON(w, r, http.StatusUnauthorized, "Missing user id in token", "error")
return
}
user, err := storage.GetOne[models.User](ctx, s.DB, "users", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.Equal("id", userID))
})
if err != nil {
slog.ErrorContext(ctx, "user with that id not found", slog.Any("id", userID.String()), "error", err.Error())
s.JSON(w, r, http.StatusUnauthorized, "Invalid user id in token", "error")
return
}
s.issueTokens(w, r, user)
}
func (s *Server) AuthRegister(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req api.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.JSON(w, r, http.StatusBadRequest, "invalid request body", "error")
return
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
s.JSON(w, r, http.StatusInternalServerError, "error while hashing password", "error")
return
}
now := time.Now()
uuid := uuid.New()
user := models.User{
ID: uuid,
Slug: s.GenerateUserSlug(req.FullName, uuid),
CreatedAt: now,
UpdatedAt: now,
Email: string(req.Email),
PasswordHash: string(passwordHash),
FullName: req.FullName,
}
if err := storage.Create[models.User](ctx, "users", user, s.DB); err != nil {
slog.ErrorContext(ctx, "Error while creating user", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, "Server error", "error")
return
}
s.issueTokens(w, r, &user)
}
func (s *Server) DeleteMe(w http.ResponseWriter, r *http.Request) {}
func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) {}
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {}
func (s *Server) issueTokens(w http.ResponseWriter, r *http.Request, user *models.User) {
access, err := s.generateAccessToken(user, s.Config.RedisAccessTokenDur())
if err != nil {
slog.ErrorContext(r.Context(), "generate access failed", slog.String("error", err.Error()))
return
}
refresh, err := s.generateRefreshToken()
if err != nil {
slog.ErrorContext(r.Context(), "generate refresh token failed", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, "Failure during generating tokens", "error")
}
key := "access_token:" + access
refreshkey := "refresh_token:" + refresh
err = s.Redis.Set(r.Context(), key, "valid", s.Config.RedisAccessTokenDur()).Err()
if err != nil {
slog.ErrorContext(r.Context(), "Failed to set access token in redis", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, "Server error", "error")
return
}
err = s.Redis.Set(r.Context(), refreshkey, "valid", s.Config.RedisRefreshTokenDur()).Err()
if err != nil {
slog.ErrorContext(r.Context(), "Failed to set refresh token in redis", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, "Server error", "error")
return
}
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: refresh,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
MaxAge: 7 * 24 * 3600,
})
s.JSON(w, r, http.StatusOK, map[string]any{
"access_token": access,
"expires_in": 86400,
}, "auth")
}
func (s *Server) deleteRefreshCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: "",
Path: "/",
MaxAge: -1,
})
}
func (s *Server) generateAccessToken(user *models.User, duration time.Duration) (string, error) {
return s.generateJWT(user, duration)
}
func (s *Server) generateRefreshToken() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func (s *Server) generateJWT(user *models.User, lifetime time.Duration) (string, error) {
if len(s.JwtKey) == 0 {
return "", errors.New("jwt key not set")
}
tokenID := hex.EncodeToString([]byte(time.Now().String() + user.ID.String()))
claims := Claims{
ID: user.ID,
Email: user.Email,
Role: user.Role,
TokenID: tokenID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(lifetime)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: user.ID.String(),
Issuer: s.Config.JwtOpt.Issuer,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.JwtKey)
}
func (s *Server) GenerateUserSlug(username string, uuid uuid.UUID) string {
if username == "" {
username = "user"
}
base := slug.Make(username)
return base + uuid.String()
}
func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {}
func (s *Server) AuthRefresh(w http.ResponseWriter, r *http.Request) {}
func (s *Server) AuthRegister(w http.ResponseWriter, r *http.Request) {}
func (s *Server) DeleteMe(w http.ResponseWriter, r *http.Request) {}
func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) {}
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {}

View File

@@ -7,13 +7,14 @@ import (
)
type User struct {
ID uuid.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Slug string `db:"slug" json:"slug"`
PasswordHash string `db:"password_hash" json:"-"`
FullName *string `db:"full_name" json:"full_name,omitempty"`
AvatarURL *string `db:"avatar_url" json:"avatar_url,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt *time.Time `db:"updated_at" json:"updated_at,omitempty"`
LastLoginAt *time.Time `db:"last_login_at" json:"last_login_at,omitempty"`
ID uuid.UUID `db:"id"`
Email string `db:"email"`
Slug string `db:"slug"`
PasswordHash string `db:"password_hash"`
FullName string `db:"full_name"`
AvatarURL string `db:"avatar_url"`
Role string `db:"role"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
LastLoginAt *time.Time `db:"last_login_at"`
}

View File

@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS users (
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(150),
avatar_url VARCHAR(512),
role VARCHAR(512),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ

View File

@@ -1,14 +0,0 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS roles(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(20),
code VARCHAR(20)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS roles;
-- +goose StatementEnd

View File

@@ -1,13 +0,0 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS user_roles(
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS user_roles;
-- +goose StatementEnd

View File

@@ -18,7 +18,6 @@ type Querier interface {
var ErrNotFound = errors.New("not found")
// GetAll функция для получения всех записей из базы данных
func GetAll[T any](ctx context.Context, table string, db Querier, opts ...func(*sqlbuilder.SelectBuilder)) ([]T, error) {
sb := sqlbuilder.NewStruct(new(T)).SelectFrom(table)
@@ -55,7 +54,6 @@ func GetAll[T any](ctx context.Context, table string, db Querier, opts ...func(*
return lists, nil
}
// GetOne функция для получения одной записи из базы данных
func GetOne[T any](ctx context.Context, db Querier, table string, opts ...func(*sqlbuilder.SelectBuilder)) (*T, error) {
itemsStruct := sqlbuilder.NewStruct(new(T)).For(sqlbuilder.PostgreSQL)
@@ -86,7 +84,6 @@ func GetOne[T any](ctx context.Context, db Querier, table string, opts ...func(*
return &item, nil
}
// Create функция для создания записи в базе данных
func Create[T any](ctx context.Context, table string, item T, db Querier, opts ...func(*sqlbuilder.SelectBuilder)) error {
structs := sqlbuilder.NewStruct(new(T))
@@ -111,7 +108,6 @@ func Create[T any](ctx context.Context, table string, item T, db Querier, opts .
return nil
}
// Update функция для обновления записи в базе данных
func Update[T any](ctx context.Context, table string, item T, db Querier, opts ...func(*sqlbuilder.UpdateBuilder)) error {
structs := sqlbuilder.NewStruct(new(T))
@@ -136,7 +132,6 @@ func Update[T any](ctx context.Context, table string, item T, db Querier, opts .
return nil
}
// Delete функция для удаления записи из базы данных
func Delete[T any](ctx context.Context, table string, db Querier, opts ...func(*sqlbuilder.SelectBuilder)) error {
structs := sqlbuilder.NewStruct(new(T))