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

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"`
}