From 4dd60f3b190ffa8b2ca91ade49d83c1b36ea295d Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 05:52:31 +0300 Subject: [PATCH 01/20] feat: TitlesFilterPanel component --- .../src/api/services/DefaultService.ts | 3 + .../TitlesFilterPanel/TitlesFilterPanel.tsx | 122 ++++++++++++++++++ .../src/pages/TitlesPage/TitlesPage.tsx | 23 +++- 3 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 218b461..6898c46 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -20,6 +20,7 @@ export class DefaultService { * @param cursor * @param sort * @param sortForward + * @param extSearch * @param word * @param status List of title statuses to filter * @param rating @@ -35,6 +36,7 @@ export class DefaultService { cursor?: string, sort?: TitleSort, sortForward: boolean = true, + extSearch: boolean = false, word?: string, status?: Array, rating?: number, @@ -57,6 +59,7 @@ export class DefaultService { 'cursor': cursor, 'sort': sort, 'sort_forward': sortForward, + 'ext_search': extSearch, 'word': word, 'status': status, 'rating': rating, diff --git a/modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx b/modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx new file mode 100644 index 0000000..3cfef69 --- /dev/null +++ b/modules/frontend/src/components/TitlesFilterPanel/TitlesFilterPanel.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import type { TitleStatus, ReleaseSeason } from "../../api"; +import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/solid"; + +export type TitlesFilter = { + extSearch: boolean; + status: TitleStatus | ""; + rating: number | ""; + releaseYear: number | ""; + releaseSeason: ReleaseSeason | ""; +}; + +type TitlesFilterPanelProps = { + filters: TitlesFilter; + setFilters: (filters: TitlesFilter) => void; +}; + +const STATUS_OPTIONS: (TitleStatus | "")[] = ["", "planned", "finished", "ongoing"]; +const SEASON_OPTIONS: (ReleaseSeason | "")[] = ["", "winter", "spring", "summer", "fall"]; +const RATING_OPTIONS = ["", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + +export function TitlesFilterPanel({ filters, setFilters }: TitlesFilterPanelProps) { + const [open, setOpen] = useState(false); + + const handleChange = (field: keyof TitlesFilter, value: any) => { + setFilters({ ...filters, [field]: value }); + }; + + return ( +
+
+ {/* Заголовок панели */} +
setOpen((prev) => !prev)} + > +

Filters

+ {open ? : } +
+ + {/* Контент панели */} + {open && ( +
+ {/* Extended Search */} +
+ handleChange("extSearch", e.target.checked)} + className="w-4 h-4" + /> + +
+ + {/* Status */} +
+ + +
+ + {/* Rating */} +
+ + +
+ + {/* Release Year */} +
+ + + handleChange("releaseYear", e.target.value ? Number(e.target.value) : "") + } + className="border rounded px-2 py-1" + placeholder="Any" + /> +
+ + {/* Release Season */} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index c9911b9..ed55d8d 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -8,6 +8,7 @@ import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal" import type { CursorObj, Title, TitleSort } from "../../api"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; import { Link } from "react-router-dom"; +import { type TitlesFilter, TitlesFilterPanel } from "../../components/TitlesFilterPanel/TitlesFilterPanel"; const PAGE_SIZE = 10; @@ -22,6 +23,14 @@ export default function TitlesPage() { const [sortForward, setSortForward] = useState(true); const [layout, setLayout] = useState<"square" | "horizontal">("square"); + const [filters, setFilters] = useState({ + extSearch: false, + status: "", + rating: "", + releaseYear: "", + releaseSeason: "", + }); + const fetchPage = async (cursorObj: CursorObj | null) => { const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') : ""; @@ -30,13 +39,14 @@ export default function TitlesPage() { cursorStr, sort, sortForward, + filters.extSearch, search.trim() || undefined, - undefined, - undefined, - undefined, - undefined, + filters.status ? [filters.status] : undefined, + filters.rating || undefined, + filters.releaseYear || undefined, + filters.releaseSeason || undefined, + PAGE_SIZE, PAGE_SIZE, - undefined, "all" ); @@ -73,7 +83,7 @@ export default function TitlesPage() { }; initLoad(); - }, [search, sort, sortForward]); + }, [search, sort, sortForward, filters]); const handleLoadMore = async () => { @@ -121,6 +131,7 @@ const handleLoadMore = async () => { setSortForward={setSortForward} /> + {loading &&
Loading...
} From 6995ce58f6d8f588f235cbaf985b7b82e76ecda1 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Thu, 4 Dec 2025 06:13:03 +0300 Subject: [PATCH 02/20] feat: csrf tokens handling --- api/_build/openapi.yaml | 39 +++++++++++++++ api/api.gen.go | 72 +++++++++++++++++++++++++-- api/parameters/_index.yaml | 8 ++- api/parameters/access_token.yaml | 9 ++++ api/parameters/xsrf_token_cookie.yaml | 11 ++++ api/parameters/xsrf_token_header.yaml | 10 ++++ api/paths/titles-id.yaml | 2 + api/paths/users-id.yaml | 4 ++ api/schemas/JWTAuth.yaml | 7 +++ api/schemas/_index.yaml | 2 + modules/backend/main.go | 4 +- modules/backend/middlewares/csrf.go | 70 ++++++++++++++++++++++++++ 12 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 api/parameters/access_token.yaml create mode 100644 api/parameters/xsrf_token_cookie.yaml create mode 100644 api/parameters/xsrf_token_header.yaml create mode 100644 api/schemas/JWTAuth.yaml create mode 100644 modules/backend/middlewares/csrf.go diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index e85ddf9..58dd890 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -120,6 +120,8 @@ paths: description: Title not found '500': description: Unknown server error + security: + - JwtAuthCookies: [] '/users/{user_id}': get: operationId: getUsersId @@ -156,6 +158,8 @@ paths: Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. parameters: + - $ref: '#/components/parameters/accessToken' + - $ref: '#/components/parameters/csrfToken' - name: user_id in: path description: User ID (primary key) @@ -223,6 +227,8 @@ paths: description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)' '500': description: Unknown server error + security: + - JwtAuthCookies: [] '/users/{user_id}/titles': get: operationId: getUserTitles @@ -474,6 +480,39 @@ paths: description: Internal server error components: parameters: + accessToken: + name: access_token + in: cookie + required: true + schema: + type: string + format: jwt + example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y + description: | + JWT access token. + csrfToken: + name: XSRF-TOKEN + in: cookie + required: true + schema: + type: string + pattern: '^[a-zA-Z0-9_-]{32,64}$' + example: abc123def456ghi789jkl012mno345pqr + description: | + Anti-CSRF token (Double Submit Cookie pattern). + Stored in non-HttpOnly cookie, readable by JavaScript. + Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). + csrfTokenHeader: + name: X-XSRF-TOKEN + in: header + required: true + schema: + type: string + pattern: '^[a-zA-Z0-9_-]{32,64}$' + description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). + example: abc123def456ghi789jkl012mno345pqr cursor: in: query name: cursor diff --git a/api/api.gen.go b/api/api.gen.go index c8fd9aa..62450e0 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -16,6 +16,10 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +const ( + JwtAuthCookiesScopes = "JwtAuthCookies.Scopes" +) + // Defines values for ReleaseSeason. const ( Fall ReleaseSeason = "fall" @@ -170,6 +174,12 @@ type UserTitleMini struct { // UserTitleStatus User's title status type UserTitleStatus string +// AccessToken defines model for accessToken. +type AccessToken = string + +// CsrfToken defines model for csrfToken. +type CsrfToken = string + // Cursor defines model for cursor. type Cursor = string @@ -219,6 +229,17 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } +// UpdateUserParams defines parameters for UpdateUser. +type UpdateUserParams struct { + // AccessToken JWT access token. + AccessToken AccessToken `form:"access_token" json:"access_token"` + + // XSRFTOKEN Anti-CSRF token (Double Submit Cookie pattern). + // Stored in non-HttpOnly cookie, readable by JavaScript. + // Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). + XSRFTOKEN CsrfToken `form:"XSRF-TOKEN" json:"XSRF-TOKEN"` +} + // GetUserTitlesParams defines parameters for GetUserTitles. type GetUserTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -276,7 +297,7 @@ type ServerInterface interface { GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) // Partially update a user account // (PATCH /users/{user_id}) - UpdateUser(c *gin.Context, userId int64) + UpdateUser(c *gin.Context, userId int64, params UpdateUserParams) // Get user titles // (GET /users/{user_id}/titles) GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) @@ -431,6 +452,8 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { return } + c.Set(JwtAuthCookiesScopes, []string{}) + // Parameter object where we will unmarshal all parameters from the context var params GetTitleParams @@ -501,6 +524,47 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { return } + c.Set(JwtAuthCookiesScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params UpdateUserParams + + { + var cookie string + + if cookie, err = c.Cookie("access_token"); err == nil { + var value AccessToken + err = runtime.BindStyledParameterWithOptions("simple", "access_token", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter access_token: %w", err), http.StatusBadRequest) + return + } + params.AccessToken = value + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument access_token is required, but not found"), http.StatusBadRequest) + return + } + } + + { + var cookie string + + if cookie, err = c.Cookie("XSRF-TOKEN"); err == nil { + var value CsrfToken + err = runtime.BindStyledParameterWithOptions("simple", "XSRF-TOKEN", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter XSRF-TOKEN: %w", err), http.StatusBadRequest) + return + } + params.XSRFTOKEN = value + + } else { + siw.ErrorHandler(c, fmt.Errorf("Query argument XSRF-TOKEN is required, but not found"), http.StatusBadRequest) + return + } + } + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -508,7 +572,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { } } - siw.Handler.UpdateUser(c, userId) + siw.Handler.UpdateUser(c, userId, params) } // GetUserTitles operation middleware @@ -935,6 +999,7 @@ func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWri type UpdateUserRequestObject struct { UserId int64 `json:"user_id"` + Params UpdateUserParams Body *UpdateUserJSONRequestBody } @@ -1411,10 +1476,11 @@ func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetU } // UpdateUser operation middleware -func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64, params UpdateUserParams) { var request UpdateUserRequestObject request.UserId = userId + request.Params = params var body UpdateUserJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { diff --git a/api/parameters/_index.yaml b/api/parameters/_index.yaml index 6249e7d..d2e12a8 100644 --- a/api/parameters/_index.yaml +++ b/api/parameters/_index.yaml @@ -1,4 +1,10 @@ cursor: $ref: "./cursor.yaml" title_sort: - $ref: "./title_sort.yaml" \ No newline at end of file + $ref: "./title_sort.yaml" +accessToken: + $ref: "./access_token.yaml" +csrfToken: + $ref: "./xsrf_token_cookie.yaml" +csrfTokenHeader: + $ref: "./xsrf_token_header.yaml" \ No newline at end of file diff --git a/api/parameters/access_token.yaml b/api/parameters/access_token.yaml new file mode 100644 index 0000000..a7e727e --- /dev/null +++ b/api/parameters/access_token.yaml @@ -0,0 +1,9 @@ +name: access_token +in: cookie +required: true +schema: + type: string + format: jwt +example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y" +description: | + JWT access token. diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml new file mode 100644 index 0000000..cf85999 --- /dev/null +++ b/api/parameters/xsrf_token_cookie.yaml @@ -0,0 +1,11 @@ +name: XSRF-TOKEN +in: cookie +required: true +schema: + type: string + pattern: "^[a-zA-Z0-9_-]{32,64}$" +example: "abc123def456ghi789jkl012mno345pqr" +description: | + Anti-CSRF token (Double Submit Cookie pattern). + Stored in non-HttpOnly cookie, readable by JavaScript. + Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). \ No newline at end of file diff --git a/api/parameters/xsrf_token_header.yaml b/api/parameters/xsrf_token_header.yaml new file mode 100644 index 0000000..ac14dc1 --- /dev/null +++ b/api/parameters/xsrf_token_header.yaml @@ -0,0 +1,10 @@ +name: X-XSRF-TOKEN +in: header +required: true +schema: + type: string + pattern: "^[a-zA-Z0-9_-]{32,64}$" +description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). +example: "abc123def456ghi789jkl012mno345pqr" \ No newline at end of file diff --git a/api/paths/titles-id.yaml b/api/paths/titles-id.yaml index 235743f..f1b9c55 100644 --- a/api/paths/titles-id.yaml +++ b/api/paths/titles-id.yaml @@ -1,5 +1,7 @@ get: summary: Get title description + security: + - JwtAuthCookies: [] operationId: getTitle parameters: - in: path diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index fe62e46..0f2f367 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -28,12 +28,16 @@ get: patch: summary: Partially update a user account + security: + - JwtAuthCookies: [] description: | Update selected user profile fields (excluding password). Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. operationId: updateUser parameters: + - $ref: '../parameters/access_token.yaml' # ← для поля в UI и GoDoc + - $ref: '../parameters/xsrf_token_cookie.yaml' # ← для CSRF - name: user_id in: path required: true diff --git a/api/schemas/JWTAuth.yaml b/api/schemas/JWTAuth.yaml new file mode 100644 index 0000000..63c3baa --- /dev/null +++ b/api/schemas/JWTAuth.yaml @@ -0,0 +1,7 @@ +# type: apiKey +# in: cookie +# name: access_token +# scheme: bearer +# bearerFormat: JWT +# description: | +# JWT access token sent in `Cookie: access_token=...`. \ No newline at end of file diff --git a/api/schemas/_index.yaml b/api/schemas/_index.yaml index d893ced..0cc0f9d 100644 --- a/api/schemas/_index.yaml +++ b/api/schemas/_index.yaml @@ -24,3 +24,5 @@ User: $ref: "./User.yaml" UserTitle: $ref: "./UserTitle.yaml" +# JwtAuth: +# $ref: "./JWTAuth.yaml" diff --git a/modules/backend/main.go b/modules/backend/main.go index 9f992a5..aab1287 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -11,6 +11,7 @@ import ( oapi "nyanimedb/api" handlers "nyanimedb/modules/backend/handlers" + middleware "nyanimedb/modules/backend/middlewares" "nyanimedb/modules/backend/rmq" "github.com/gin-contrib/cors" @@ -45,6 +46,8 @@ func main() { r := gin.Default() + r.Use(middleware.CSRFMiddleware()) + // jwt middle will be here queries := sqlc.New(pool) // === RabbitMQ setup === @@ -63,7 +66,6 @@ func main() { rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) server := handlers.NewServer(queries, publisher, rpcClient) - // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production diff --git a/modules/backend/middlewares/csrf.go b/modules/backend/middlewares/csrf.go new file mode 100644 index 0000000..41fad7b --- /dev/null +++ b/modules/backend/middlewares/csrf.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + + "github.com/gin-gonic/gin" +) + +// CSRFMiddleware для Gin +func CSRFMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Пропускаем безопасные методы + if !isStateChangingMethod(c.Request.Method) { + c.Next() + return + } + + // 1. Получаем токен из заголовка + headerToken := c.GetHeader("X-XSRF-TOKEN") + if headerToken == "" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "missing X-XSRF-TOKEN header", + }) + return + } + + // 2. Получаем токен из cookie + cookie, err := c.Cookie("xsrf_token") + if err != nil { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "missing xsrf_token cookie", + }) + return + } + + // 3. Безопасное сравнение + if subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookie)) != 1 { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "CSRF token mismatch", + }) + return + } + + // 4. Опционально: сохраняем токен в контексте + c.Set("csrf_token", headerToken) + c.Next() + } +} + +func isStateChangingMethod(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + default: + return false + } +} + +// CSRFTokenFromGin извлекает токен из Gin context +func CSRFTokenFromGin(c *gin.Context) (string, bool) { + token, exists := c.Get("xsrf_token") + if !exists { + return "", false + } + if s, ok := token.(string); ok { + return s, true + } + return "", false +} From ef871833c585e15fcba5e69a5e97bccb53e42eeb Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 06:29:20 +0300 Subject: [PATCH 03/20] feat: xsrf_token set --- deploy/docker-compose.yml | 2 ++ modules/auth/handlers/handlers.go | 38 +++++++++++++-------- modules/auth/helpers.go | 33 ++++++++++++++++++ modules/auth/main.go | 57 +++++++++++++++++++++++++++++-- modules/auth/types.go | 7 ++-- 5 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 modules/auth/helpers.go diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 79ad2f5..0ae97c6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -62,6 +62,8 @@ services: environment: LOG_LEVEL: ${LOG_LEVEL} DATABASE_URL: ${DATABASE_URL} + SERVICE_ADDRESS: ${SERVICE_ADDRESS} + JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} ports: - "8082:8082" depends_on: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 261826c..6fee512 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -2,6 +2,8 @@ package handlers import ( "context" + "crypto/rand" + "encoding/base64" "fmt" "net/http" auth "nyanimedb/auth" @@ -15,15 +17,13 @@ import ( log "github.com/sirupsen/logrus" ) -var accessSecret = []byte("my_access_secret_key") -var refreshSecret = []byte("my_refresh_secret_key") - type Server struct { - db *sqlc.Queries + db *sqlc.Queries + JwtPrivateKey string } -func NewServer(db *sqlc.Queries) Server { - return Server{db: db} +func NewServer(db *sqlc.Queries, JwtPrivatekey string) Server { + return Server{db: db, JwtPrivateKey: JwtPrivatekey} } func parseInt64(s string) (int32, error) { @@ -47,15 +47,15 @@ func CheckPassword(password, hash string) (bool, error) { return argon2id.ComparePasswordAndHash(password, hash) } -func generateTokens(userID string) (accessToken string, refreshToken string, err error) { +func (s Server) generateTokens(userID string) (accessToken string, refreshToken string, csrfToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, "exp": time.Now().Add(15 * time.Minute).Unix(), } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(accessSecret) + accessToken, err = at.SignedString(s.JwtPrivateKey) if err != nil { - return "", "", err + return "", "", "", err } refreshClaims := jwt.MapClaims{ @@ -63,12 +63,19 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), } rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) - refreshToken, err = rt.SignedString(refreshSecret) + refreshToken, err = rt.SignedString(s.JwtPrivateKey) if err != nil { - return "", "", err + return "", "", "", err } - return accessToken, refreshToken, nil + csrfBytes := make([]byte, 32) + _, err = rand.Read(csrfBytes) + if err != nil { + return "", "", "", err + } + csrfToken = base64.RawURLEncoding.EncodeToString(csrfBytes) + + return accessToken, refreshToken, csrfToken, nil } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { @@ -118,7 +125,7 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque }, nil } - accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + accessToken, refreshToken, csrfToken, err := s.generateTokens(req.Body.Nickname) if err != nil { log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) // TODO: return 500 @@ -126,8 +133,9 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) + ginCtx.SetCookie("access_token", accessToken, 900, "/api", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 1209600, "/auth", "", false, true) + ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/api", "", false, false) result := auth.PostAuthSignIn200JSONResponse{ UserId: user.ID, diff --git a/modules/auth/helpers.go b/modules/auth/helpers.go new file mode 100644 index 0000000..9c3ab36 --- /dev/null +++ b/modules/auth/helpers.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "reflect" +) + +func setField(obj interface{}, name string, value interface{}) error { + v := reflect.ValueOf(obj) + + if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct { + return fmt.Errorf("expected pointer to a struct") + } + + v = v.Elem() + field := v.FieldByName(name) + + if !field.IsValid() { + return fmt.Errorf("no such field: %s", name) + } + if !field.CanSet() { + return fmt.Errorf("cannot set field: %s", name) + } + + val := reflect.ValueOf(value) + + if field.Type() != val.Type() { + return fmt.Errorf("provided value type (%s) doesn't match field type (%s)", val.Type(), field.Type()) + } + + field.Set(val) + return nil +} diff --git a/modules/auth/main.go b/modules/auth/main.go index 7554f42..ef9b977 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "reflect" "time" auth "nyanimedb/auth" @@ -13,12 +14,24 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" + "github.com/pelletier/go-toml/v2" + log "github.com/sirupsen/logrus" ) var AppConfig Config func main() { - // TODO: env args + if len(os.Args) != 2 { + AppConfig.Mode = "env" + } else { + AppConfig.Mode = "argv" + } + + err := InitConfig() + if err != nil { + log.Fatalf("Failed to init config: %v\n", err) + } + r := gin.Default() pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) @@ -29,10 +42,10 @@ func main() { var queries *sqlc.Queries = sqlc.New(pool) - server := handlers.NewServer(queries) + server := handlers.NewServer(queries, AppConfig.JwtPrivateKey) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, @@ -47,3 +60,41 @@ func main() { r.Run(":8082") } + +func InitConfig() error { + if AppConfig.Mode == "argv" { + content, err := os.ReadFile(os.Args[1]) + if err != nil { + return err + } + + toml.Unmarshal(content, &AppConfig) + + fmt.Printf("%+v\n", AppConfig) + + return nil + } else if AppConfig.Mode == "env" { + f := reflect.ValueOf(AppConfig) + + for i := 0; i < f.NumField(); i++ { + field := f.Type().Field(i) + tag := field.Tag + env_var := tag.Get("env") + fmt.Printf("Field: %v.\nEnvironment variable: %v.\n", field.Name, env_var) + if env_var != "" { + env_value, exists := os.LookupEnv(env_var) + if !exists { + return fmt.Errorf("there is no env variable %s", env_var) + } + err := setField(&AppConfig, field.Name, env_value) + if err != nil { + return fmt.Errorf("failed to set config field %s: %v", field.Name, err) + } + } + } + + return nil + } else { + return fmt.Errorf("incorrect config mode") + } +} diff --git a/modules/auth/types.go b/modules/auth/types.go index 038b179..694843e 100644 --- a/modules/auth/types.go +++ b/modules/auth/types.go @@ -1,6 +1,9 @@ package main type Config struct { - JwtPrivateKey string - LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` + Mode string + ServiceAddress string `toml:"ServiceAddress" env:"SERVICE_ADDRESS"` + DdUrl string `toml:"DbUrl" env:"DATABASE_URL"` + JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` } From b79a6b9117e4a7384398541105c801e81e0351d2 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 06:29:20 +0300 Subject: [PATCH 04/20] feat: xsrf_token set --- deploy/docker-compose.yml | 2 ++ modules/auth/handlers/handlers.go | 38 +++++++++++++-------- modules/auth/helpers.go | 33 ++++++++++++++++++ modules/auth/main.go | 57 +++++++++++++++++++++++++++++-- modules/auth/types.go | 7 ++-- 5 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 modules/auth/helpers.go diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 79ad2f5..0ae97c6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -62,6 +62,8 @@ services: environment: LOG_LEVEL: ${LOG_LEVEL} DATABASE_URL: ${DATABASE_URL} + SERVICE_ADDRESS: ${SERVICE_ADDRESS} + JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} ports: - "8082:8082" depends_on: diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 261826c..6fee512 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -2,6 +2,8 @@ package handlers import ( "context" + "crypto/rand" + "encoding/base64" "fmt" "net/http" auth "nyanimedb/auth" @@ -15,15 +17,13 @@ import ( log "github.com/sirupsen/logrus" ) -var accessSecret = []byte("my_access_secret_key") -var refreshSecret = []byte("my_refresh_secret_key") - type Server struct { - db *sqlc.Queries + db *sqlc.Queries + JwtPrivateKey string } -func NewServer(db *sqlc.Queries) Server { - return Server{db: db} +func NewServer(db *sqlc.Queries, JwtPrivatekey string) Server { + return Server{db: db, JwtPrivateKey: JwtPrivatekey} } func parseInt64(s string) (int32, error) { @@ -47,15 +47,15 @@ func CheckPassword(password, hash string) (bool, error) { return argon2id.ComparePasswordAndHash(password, hash) } -func generateTokens(userID string) (accessToken string, refreshToken string, err error) { +func (s Server) generateTokens(userID string) (accessToken string, refreshToken string, csrfToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, "exp": time.Now().Add(15 * time.Minute).Unix(), } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(accessSecret) + accessToken, err = at.SignedString(s.JwtPrivateKey) if err != nil { - return "", "", err + return "", "", "", err } refreshClaims := jwt.MapClaims{ @@ -63,12 +63,19 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), } rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) - refreshToken, err = rt.SignedString(refreshSecret) + refreshToken, err = rt.SignedString(s.JwtPrivateKey) if err != nil { - return "", "", err + return "", "", "", err } - return accessToken, refreshToken, nil + csrfBytes := make([]byte, 32) + _, err = rand.Read(csrfBytes) + if err != nil { + return "", "", "", err + } + csrfToken = base64.RawURLEncoding.EncodeToString(csrfBytes) + + return accessToken, refreshToken, csrfToken, nil } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { @@ -118,7 +125,7 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque }, nil } - accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + accessToken, refreshToken, csrfToken, err := s.generateTokens(req.Body.Nickname) if err != nil { log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) // TODO: return 500 @@ -126,8 +133,9 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) + ginCtx.SetCookie("access_token", accessToken, 900, "/api", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 1209600, "/auth", "", false, true) + ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/api", "", false, false) result := auth.PostAuthSignIn200JSONResponse{ UserId: user.ID, diff --git a/modules/auth/helpers.go b/modules/auth/helpers.go new file mode 100644 index 0000000..9c3ab36 --- /dev/null +++ b/modules/auth/helpers.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "reflect" +) + +func setField(obj interface{}, name string, value interface{}) error { + v := reflect.ValueOf(obj) + + if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct { + return fmt.Errorf("expected pointer to a struct") + } + + v = v.Elem() + field := v.FieldByName(name) + + if !field.IsValid() { + return fmt.Errorf("no such field: %s", name) + } + if !field.CanSet() { + return fmt.Errorf("cannot set field: %s", name) + } + + val := reflect.ValueOf(value) + + if field.Type() != val.Type() { + return fmt.Errorf("provided value type (%s) doesn't match field type (%s)", val.Type(), field.Type()) + } + + field.Set(val) + return nil +} diff --git a/modules/auth/main.go b/modules/auth/main.go index 7554f42..ef9b977 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "reflect" "time" auth "nyanimedb/auth" @@ -13,12 +14,24 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" + "github.com/pelletier/go-toml/v2" + log "github.com/sirupsen/logrus" ) var AppConfig Config func main() { - // TODO: env args + if len(os.Args) != 2 { + AppConfig.Mode = "env" + } else { + AppConfig.Mode = "argv" + } + + err := InitConfig() + if err != nil { + log.Fatalf("Failed to init config: %v\n", err) + } + r := gin.Default() pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) @@ -29,10 +42,10 @@ func main() { var queries *sqlc.Queries = sqlc.New(pool) - server := handlers.NewServer(queries) + server := handlers.NewServer(queries, AppConfig.JwtPrivateKey) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, @@ -47,3 +60,41 @@ func main() { r.Run(":8082") } + +func InitConfig() error { + if AppConfig.Mode == "argv" { + content, err := os.ReadFile(os.Args[1]) + if err != nil { + return err + } + + toml.Unmarshal(content, &AppConfig) + + fmt.Printf("%+v\n", AppConfig) + + return nil + } else if AppConfig.Mode == "env" { + f := reflect.ValueOf(AppConfig) + + for i := 0; i < f.NumField(); i++ { + field := f.Type().Field(i) + tag := field.Tag + env_var := tag.Get("env") + fmt.Printf("Field: %v.\nEnvironment variable: %v.\n", field.Name, env_var) + if env_var != "" { + env_value, exists := os.LookupEnv(env_var) + if !exists { + return fmt.Errorf("there is no env variable %s", env_var) + } + err := setField(&AppConfig, field.Name, env_value) + if err != nil { + return fmt.Errorf("failed to set config field %s: %v", field.Name, err) + } + } + } + + return nil + } else { + return fmt.Errorf("incorrect config mode") + } +} diff --git a/modules/auth/types.go b/modules/auth/types.go index 038b179..694843e 100644 --- a/modules/auth/types.go +++ b/modules/auth/types.go @@ -1,6 +1,9 @@ package main type Config struct { - JwtPrivateKey string - LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` + Mode string + ServiceAddress string `toml:"ServiceAddress" env:"SERVICE_ADDRESS"` + DdUrl string `toml:"DbUrl" env:"DATABASE_URL"` + JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` } From 7629f391ad0889af10eb44a359c8bd0a1aa6a6e3 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Thu, 4 Dec 2025 06:42:08 +0300 Subject: [PATCH 05/20] fix --- api/parameters/xsrf_token_cookie.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml index cf85999..37041e0 100644 --- a/api/parameters/xsrf_token_cookie.yaml +++ b/api/parameters/xsrf_token_cookie.yaml @@ -1,4 +1,4 @@ -name: XSRF-TOKEN +name: xsrf_token in: cookie required: true schema: From 1bbfa338d92b4122a658bb3487c98666aae4652a Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 07:17:31 +0300 Subject: [PATCH 06/20] feat: send xsrf_token header --- api/_build/openapi.yaml | 15 ++++-- api/parameters/xsrf_token_cookie.yaml | 2 +- api/paths/users-id-titles-id.yaml | 8 +++ api/paths/users-id.yaml | 5 +- auth/openapi-auth.yaml | 4 +- modules/frontend/package-lock.json | 53 ++++++++++++++++++- modules/frontend/package.json | 1 + modules/frontend/src/api/index.ts | 3 ++ .../frontend/src/api/models/accessToken.ts | 9 ++++ modules/frontend/src/api/models/csrfToken.ts | 11 ++++ .../src/api/models/csrfTokenHeader.ts | 10 ++++ .../src/api/services/DefaultService.ts | 21 ++++++++ .../frontend/src/auth/services/AuthService.ts | 17 +++--- .../TitleStatusControls.tsx | 9 +++- .../src/pages/LoginPage/LoginPage.tsx | 10 ++-- 15 files changed, 151 insertions(+), 27 deletions(-) create mode 100644 modules/frontend/src/api/models/accessToken.ts create mode 100644 modules/frontend/src/api/models/csrfToken.ts create mode 100644 modules/frontend/src/api/models/csrfTokenHeader.ts diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 58dd890..225e7cd 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -150,6 +150,8 @@ paths: description: User not found '500': description: Unknown server error + security: + - JwtAuthCookies: [] patch: operationId: updateUser summary: Partially update a user account @@ -158,8 +160,7 @@ paths: Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. parameters: - - $ref: '#/components/parameters/accessToken' - - $ref: '#/components/parameters/csrfToken' + - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path description: User ID (primary key) @@ -404,11 +405,14 @@ paths: description: User or title not found '500': description: Unknown server error + security: + - JwtAuthCookies: [] patch: operationId: updateUserTitle summary: Update a usertitle description: User updating title list of watched parameters: + - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -450,11 +454,14 @@ paths: description: User or Title not found '500': description: Internal server error + security: + - JwtAuthCookies: [] delete: operationId: deleteUserTitle summary: Delete a usertitle description: User deleting title from list of watched parameters: + - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -478,6 +485,8 @@ paths: description: User or Title not found '500': description: Internal server error + security: + - JwtAuthCookies: [] components: parameters: accessToken: @@ -491,7 +500,7 @@ components: description: | JWT access token. csrfToken: - name: XSRF-TOKEN + name: xsrf_token in: cookie required: true schema: diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml index cf85999..37041e0 100644 --- a/api/parameters/xsrf_token_cookie.yaml +++ b/api/parameters/xsrf_token_cookie.yaml @@ -1,4 +1,4 @@ -name: XSRF-TOKEN +name: xsrf_token in: cookie required: true schema: diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml index b4ad884..b56d07a 100644 --- a/api/paths/users-id-titles-id.yaml +++ b/api/paths/users-id-titles-id.yaml @@ -1,6 +1,8 @@ get: summary: Get user title operationId: getUserTitle + security: + - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -34,7 +36,10 @@ patch: summary: Update a usertitle description: User updating title list of watched operationId: updateUserTitle + security: + - JwtAuthCookies: [] parameters: + - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true @@ -81,7 +86,10 @@ delete: summary: Delete a usertitle description: User deleting title from list of watched operationId: deleteUserTitle + security: + - JwtAuthCookies: [] parameters: + - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 0f2f367..abb170e 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -1,6 +1,8 @@ get: summary: Get user info operationId: getUsersId + security: + - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -36,8 +38,7 @@ patch: Fields not provided in the request body remain unchanged. operationId: updateUser parameters: - - $ref: '../parameters/access_token.yaml' # ← для поля в UI и GoDoc - - $ref: '../parameters/xsrf_token_cookie.yaml' # ← для CSRF + - $ref: '../parameters/xsrf_token_header.yaml' - name: user_id in: path required: true diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 239b03b..5f3ebd6 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -7,7 +7,7 @@ servers: - url: /auth paths: - /auth/sign-up: + /sign-up: post: summary: Sign up a new user tags: [Auth] @@ -38,7 +38,7 @@ paths: type: integer format: int64 - /auth/sign-in: + /sign-in: post: summary: Sign in a user and return JWT tags: [Auth] diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index 40bb520..d2b5573 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -13,6 +13,7 @@ "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", + "react-cookie": "^8.0.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", "tailwindcss": "^4.1.17" @@ -1868,6 +1869,18 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1890,7 +1903,6 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -2524,7 +2536,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -3260,6 +3271,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4068,6 +4088,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-cookie": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-8.0.1.tgz", + "integrity": "sha512-QNdAd0MLuAiDiLcDU/2s/eyKmmfMHtjPUKJ2dZ/5CcQ9QKUium4B3o61/haq6PQl/YWFqC5PO8GvxeHKhy3GFA==", + "license": "MIT", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.6", + "hoist-non-react-statics": "^3.3.2", + "universal-cookie": "^8.0.0" + }, + "peerDependencies": { + "react": ">= 16.3.0" + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", @@ -4081,6 +4115,12 @@ "react": "^19.2.0" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4481,6 +4521,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/universal-cookie": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-8.0.1.tgz", + "integrity": "sha512-B6ks9FLLnP1UbPPcveOidfvB9pHjP+wekP2uRYB9YDfKVpvcjKgy1W5Zj+cEXJ9KTPnqOKGfVDQBmn8/YCQfRg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index e0b65ba..af07b41 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -15,6 +15,7 @@ "@tailwindcss/vite": "^4.1.17", "axios": "^1.12.2", "react": "^19.1.1", + "react-cookie": "^8.0.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", "tailwindcss": "^4.1.17" diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index 9013fc7..c1e9cdc 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -7,6 +7,9 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; +export type { accessToken } from './models/accessToken'; +export type { csrfToken } from './models/csrfToken'; +export type { csrfTokenHeader } from './models/csrfTokenHeader'; export type { cursor } from './models/cursor'; export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; diff --git a/modules/frontend/src/api/models/accessToken.ts b/modules/frontend/src/api/models/accessToken.ts new file mode 100644 index 0000000..adc8fb7 --- /dev/null +++ b/modules/frontend/src/api/models/accessToken.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * JWT access token. + * + */ +export type accessToken = string; diff --git a/modules/frontend/src/api/models/csrfToken.ts b/modules/frontend/src/api/models/csrfToken.ts new file mode 100644 index 0000000..4af805b --- /dev/null +++ b/modules/frontend/src/api/models/csrfToken.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Anti-CSRF token (Double Submit Cookie pattern). + * Stored in non-HttpOnly cookie, readable by JavaScript. + * Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). + * + */ +export type csrfToken = string; diff --git a/modules/frontend/src/api/models/csrfTokenHeader.ts b/modules/frontend/src/api/models/csrfTokenHeader.ts new file mode 100644 index 0000000..354c8a3 --- /dev/null +++ b/modules/frontend/src/api/models/csrfTokenHeader.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * + */ +export type csrfTokenHeader = string; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 6898c46..f3d803d 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -135,12 +135,16 @@ export class DefaultService { * Password updates must be done via the dedicated auth-service (`/auth/`). * Fields not provided in the request body remain unchanged. * + * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * * @param userId User ID (primary key) * @param requestBody * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). * @throws ApiError */ public static updateUser( + xXsrfToken: string, userId: number, requestBody: { /** @@ -171,6 +175,9 @@ export class DefaultService { path: { 'user_id': userId, }, + headers: { + 'X-XSRF-TOKEN': xXsrfToken, + }, body: requestBody, mediaType: 'application/json', errors: { @@ -309,6 +316,9 @@ export class DefaultService { /** * Update a usertitle * User updating title list of watched + * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * * @param userId * @param titleId * @param requestBody @@ -316,6 +326,7 @@ export class DefaultService { * @throws ApiError */ public static updateUserTitle( + xXsrfToken: string, userId: number, titleId: number, requestBody: { @@ -330,6 +341,9 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, + headers: { + 'X-XSRF-TOKEN': xXsrfToken, + }, body: requestBody, mediaType: 'application/json', errors: { @@ -344,12 +358,16 @@ export class DefaultService { /** * Delete a usertitle * User deleting title from list of watched + * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + * Required for all state-changing requests (POST/PUT/PATCH/DELETE). + * * @param userId * @param titleId * @returns any Title successfully deleted * @throws ApiError */ public static deleteUserTitle( + xXsrfToken: string, userId: number, titleId: number, ): CancelablePromise { @@ -360,6 +378,9 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, + headers: { + 'X-XSRF-TOKEN': xXsrfToken, + }, errors: { 401: `Unauthorized — missing or invalid auth token`, 403: `Forbidden — user not allowed to delete title`, diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts index 94578d8..74a8fa7 100644 --- a/modules/frontend/src/auth/services/AuthService.ts +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -12,19 +12,17 @@ export class AuthService { * @returns any Sign-up result * @throws ApiError */ - public static postAuthSignUp( + public static postSignUp( requestBody: { nickname: string; pass: string; }, ): CancelablePromise<{ - success?: boolean; - error?: string | null; - user_id?: string | null; + user_id: number; }> { return __request(OpenAPI, { method: 'POST', - url: '/auth/sign-up', + url: '/sign-up', body: requestBody, mediaType: 'application/json', }); @@ -35,19 +33,18 @@ export class AuthService { * @returns any Sign-in result with JWT * @throws ApiError */ - public static postAuthSignIn( + public static postSignIn( requestBody: { nickname: string; pass: string; }, ): CancelablePromise<{ - error?: string | null; - user_id?: string | null; - user_name?: string | null; + user_id: number; + user_name: string; }> { return __request(OpenAPI, { method: 'POST', - url: '/auth/sign-in', + url: '/sign-in', body: requestBody, mediaType: 'application/json', errors: { diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 0c9c741..4fb535a 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; import { DefaultService } from "../../api"; import type { UserTitleStatus } from "../../api"; +import { useCookies } from 'react-cookie'; + import { ClockIcon, CheckCircleIcon, @@ -17,6 +19,9 @@ const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: s ]; export function TitleStatusControls({ titleId }: { titleId: number }) { + const [cookies] = useCookies(['xsrf_token']); + const xsrfToken = cookies['xsrf_token'] || null; + const [currentStatus, setCurrentStatus] = useState(null); const [loading, setLoading] = useState(false); @@ -41,7 +46,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { try { // 1) Если кликнули на текущий статус — DELETE if (currentStatus === status) { - await DefaultService.deleteUserTitle(userId, titleId); + await DefaultService.deleteUserTitle(xsrfToken, userId, titleId); setCurrentStatus(null); return; } @@ -56,7 +61,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { setCurrentStatus(added.status); } else { // уже есть запись — PATCH - const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); + const updated = await DefaultService.updateUserTitle(xsrfToken, userId, titleId, { status }); setCurrentStatus(updated.status); } } finally { diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx index 89ee88c..928766e 100644 --- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -17,23 +17,23 @@ export const LoginPage: React.FC = () => { try { if (isLogin) { - const res = await AuthService.postAuthSignIn({ nickname, pass: password }); + const res = await AuthService.postSignIn({ nickname, pass: password }); if (res.user_id && res.user_name) { // Сохраняем user_id и username в localStorage - localStorage.setItem("userId", res.user_id); + localStorage.setItem("userId", res.user_id.toString()); localStorage.setItem("username", res.user_name); navigate("/profile"); // редирект на профиль } else { - setError(res.error || "Login failed"); + setError("Login failed"); } } else { // SignUp оставляем без сохранения данных - const res = await AuthService.postAuthSignUp({ nickname, pass: password }); + const res = await AuthService.postSignUp({ nickname, pass: password }); if (res.user_id) { setIsLogin(true); // переключаемся на login после регистрации } else { - setError(res.error || "Sign up failed"); + setError("Sign up failed"); } } } catch (err: any) { From b03f9c9704d93e596b55a474ba3656f9ba8e61b9 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 07:20:10 +0300 Subject: [PATCH 07/20] fix: regen oapi for auth --- auth/auth.gen.go | 108 +++++++++++++++--------------- modules/auth/handlers/handlers.go | 12 ++-- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/auth/auth.gen.go b/auth/auth.gen.go index 7276545..b7cd839 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -13,32 +13,32 @@ import ( strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" ) -// PostAuthSignInJSONBody defines parameters for PostAuthSignIn. -type PostAuthSignInJSONBody struct { +// PostSignInJSONBody defines parameters for PostSignIn. +type PostSignInJSONBody struct { Nickname string `json:"nickname"` Pass string `json:"pass"` } -// PostAuthSignUpJSONBody defines parameters for PostAuthSignUp. -type PostAuthSignUpJSONBody struct { +// PostSignUpJSONBody defines parameters for PostSignUp. +type PostSignUpJSONBody struct { Nickname string `json:"nickname"` Pass string `json:"pass"` } -// PostAuthSignInJSONRequestBody defines body for PostAuthSignIn for application/json ContentType. -type PostAuthSignInJSONRequestBody PostAuthSignInJSONBody +// PostSignInJSONRequestBody defines body for PostSignIn for application/json ContentType. +type PostSignInJSONRequestBody PostSignInJSONBody -// PostAuthSignUpJSONRequestBody defines body for PostAuthSignUp for application/json ContentType. -type PostAuthSignUpJSONRequestBody PostAuthSignUpJSONBody +// PostSignUpJSONRequestBody defines body for PostSignUp for application/json ContentType. +type PostSignUpJSONRequestBody PostSignUpJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { // Sign in a user and return JWT - // (POST /auth/sign-in) - PostAuthSignIn(c *gin.Context) + // (POST /sign-in) + PostSignIn(c *gin.Context) // Sign up a new user - // (POST /auth/sign-up) - PostAuthSignUp(c *gin.Context) + // (POST /sign-up) + PostSignUp(c *gin.Context) } // ServerInterfaceWrapper converts contexts to parameters. @@ -50,8 +50,8 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) -// PostAuthSignIn operation middleware -func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { +// PostSignIn operation middleware +func (siw *ServerInterfaceWrapper) PostSignIn(c *gin.Context) { for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -60,11 +60,11 @@ func (siw *ServerInterfaceWrapper) PostAuthSignIn(c *gin.Context) { } } - siw.Handler.PostAuthSignIn(c) + siw.Handler.PostSignIn(c) } -// PostAuthSignUp operation middleware -func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { +// PostSignUp operation middleware +func (siw *ServerInterfaceWrapper) PostSignUp(c *gin.Context) { for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -73,7 +73,7 @@ func (siw *ServerInterfaceWrapper) PostAuthSignUp(c *gin.Context) { } } - siw.Handler.PostAuthSignUp(c) + siw.Handler.PostSignUp(c) } // GinServerOptions provides options for the Gin server. @@ -103,54 +103,54 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } - router.POST(options.BaseURL+"/auth/sign-in", wrapper.PostAuthSignIn) - router.POST(options.BaseURL+"/auth/sign-up", wrapper.PostAuthSignUp) + router.POST(options.BaseURL+"/sign-in", wrapper.PostSignIn) + router.POST(options.BaseURL+"/sign-up", wrapper.PostSignUp) } -type PostAuthSignInRequestObject struct { - Body *PostAuthSignInJSONRequestBody +type PostSignInRequestObject struct { + Body *PostSignInJSONRequestBody } -type PostAuthSignInResponseObject interface { - VisitPostAuthSignInResponse(w http.ResponseWriter) error +type PostSignInResponseObject interface { + VisitPostSignInResponse(w http.ResponseWriter) error } -type PostAuthSignIn200JSONResponse struct { +type PostSignIn200JSONResponse struct { UserId int64 `json:"user_id"` UserName string `json:"user_name"` } -func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { +func (response PostSignIn200JSONResponse) VisitPostSignInResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type PostAuthSignIn401JSONResponse struct { +type PostSignIn401JSONResponse struct { Error *string `json:"error,omitempty"` } -func (response PostAuthSignIn401JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { +func (response PostSignIn401JSONResponse) VisitPostSignInResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(401) return json.NewEncoder(w).Encode(response) } -type PostAuthSignUpRequestObject struct { - Body *PostAuthSignUpJSONRequestBody +type PostSignUpRequestObject struct { + Body *PostSignUpJSONRequestBody } -type PostAuthSignUpResponseObject interface { - VisitPostAuthSignUpResponse(w http.ResponseWriter) error +type PostSignUpResponseObject interface { + VisitPostSignUpResponse(w http.ResponseWriter) error } -type PostAuthSignUp200JSONResponse struct { +type PostSignUp200JSONResponse struct { UserId int64 `json:"user_id"` } -func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { +func (response PostSignUp200JSONResponse) VisitPostSignUpResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) @@ -160,11 +160,11 @@ func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Sign in a user and return JWT - // (POST /auth/sign-in) - PostAuthSignIn(ctx context.Context, request PostAuthSignInRequestObject) (PostAuthSignInResponseObject, error) + // (POST /sign-in) + PostSignIn(ctx context.Context, request PostSignInRequestObject) (PostSignInResponseObject, error) // Sign up a new user - // (POST /auth/sign-up) - PostAuthSignUp(ctx context.Context, request PostAuthSignUpRequestObject) (PostAuthSignUpResponseObject, error) + // (POST /sign-up) + PostSignUp(ctx context.Context, request PostSignUpRequestObject) (PostSignUpResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -179,11 +179,11 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } -// PostAuthSignIn operation middleware -func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { - var request PostAuthSignInRequestObject +// PostSignIn operation middleware +func (sh *strictHandler) PostSignIn(ctx *gin.Context) { + var request PostSignInRequestObject - var body PostAuthSignInJSONRequestBody + var body PostSignInJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { ctx.Status(http.StatusBadRequest) ctx.Error(err) @@ -192,10 +192,10 @@ func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { request.Body = &body handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostAuthSignIn(ctx, request.(PostAuthSignInRequestObject)) + return sh.ssi.PostSignIn(ctx, request.(PostSignInRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostAuthSignIn") + handler = middleware(handler, "PostSignIn") } response, err := handler(ctx, request) @@ -203,8 +203,8 @@ func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostAuthSignInResponseObject); ok { - if err := validResponse.VisitPostAuthSignInResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(PostSignInResponseObject); ok { + if err := validResponse.VisitPostSignInResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -212,11 +212,11 @@ func (sh *strictHandler) PostAuthSignIn(ctx *gin.Context) { } } -// PostAuthSignUp operation middleware -func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { - var request PostAuthSignUpRequestObject +// PostSignUp operation middleware +func (sh *strictHandler) PostSignUp(ctx *gin.Context) { + var request PostSignUpRequestObject - var body PostAuthSignUpJSONRequestBody + var body PostSignUpJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { ctx.Status(http.StatusBadRequest) ctx.Error(err) @@ -225,10 +225,10 @@ func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { request.Body = &body handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostAuthSignUp(ctx, request.(PostAuthSignUpRequestObject)) + return sh.ssi.PostSignUp(ctx, request.(PostSignUpRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostAuthSignUp") + handler = middleware(handler, "PostSignUp") } response, err := handler(ctx, request) @@ -236,8 +236,8 @@ func (sh *strictHandler) PostAuthSignUp(ctx *gin.Context) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(PostAuthSignUpResponseObject); ok { - if err := validResponse.VisitPostAuthSignUpResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(PostSignUpResponseObject); ok { + if err := validResponse.VisitPostSignUpResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 6fee512..09907bc 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -78,7 +78,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken return accessToken, refreshToken, csrfToken, nil } -func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { +func (s Server) PostSignUp(ctx context.Context, req auth.PostSignUpRequestObject) (auth.PostSignUpResponseObject, error) { passhash, err := HashPassword(req.Body.Pass) if err != nil { log.Errorf("failed to hash password: %v", err) @@ -94,17 +94,17 @@ func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpReque // TODO: check err and retyrn 400/500 } - return auth.PostAuthSignUp200JSONResponse{ + return auth.PostSignUp200JSONResponse{ UserId: user_id, }, nil } -func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { +func (s Server) PostSignIn(ctx context.Context, req auth.PostSignInRequestObject) (auth.PostSignInResponseObject, error) { ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") // TODO: change to 500 - return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") + return auth.PostSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) @@ -120,7 +120,7 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque } if !ok { err_msg := "invalid credentials" - return auth.PostAuthSignIn401JSONResponse{ + return auth.PostSignIn401JSONResponse{ Error: &err_msg, }, nil } @@ -137,7 +137,7 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque ginCtx.SetCookie("refresh_token", refreshToken, 1209600, "/auth", "", false, true) ginCtx.SetCookie("xsrf_token", csrfToken, 1209600, "/api", "", false, false) - result := auth.PostAuthSignIn200JSONResponse{ + result := auth.PostSignIn200JSONResponse{ UserId: user.ID, UserName: user.Nickname, } From 6786f7ac00741960ef886b6f352ea36811fd9084 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Thu, 4 Dec 2025 07:32:45 +0300 Subject: [PATCH 08/20] feat: access token check --- modules/backend/main.go | 35 ++++----- modules/backend/middlewares/access.go | 109 ++++++++++++++++++++++++++ modules/backend/types.go | 14 ++-- 3 files changed, 130 insertions(+), 28 deletions(-) create mode 100644 modules/backend/middlewares/access.go diff --git a/modules/backend/main.go b/modules/backend/main.go index aab1287..0cffdcf 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -25,18 +25,18 @@ import ( var AppConfig Config func main() { - // if len(os.Args) != 2 { - // AppConfig.Mode = "env" - // } else { - // AppConfig.Mode = "argv" - // } + if len(os.Args) != 2 { + AppConfig.Mode = "env" + } else { + AppConfig.Mode = "argv" + } - // err := InitConfig() - // if err != nil { - // log.Fatalf("Failed to init config: %v\n", err) - // } + err := InitConfig() + if err != nil { + log.Fatalf("Failed to init config: %v\n", err) + } - pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) + pool, err := pgxpool.New(context.Background(), AppConfig.DdUrl) if err != nil { fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) os.Exit(1) @@ -47,16 +47,11 @@ func main() { r := gin.Default() r.Use(middleware.CSRFMiddleware()) - // jwt middle will be here + r.Use(middleware.JWTAuthMiddleware(AppConfig.JwtPrivateKey)) + queries := sqlc.New(pool) - // === RabbitMQ setup === - rmqURL := os.Getenv("RABBITMQ_URL") - if rmqURL == "" { - rmqURL = "amqp://guest:guest@rabbitmq:5672/" - } - - rmqConn, err := amqp091.Dial(rmqURL) + rmqConn, err := amqp091.Dial(AppConfig.rmqURL) if err != nil { log.Fatalf("Failed to connect to RabbitMQ: %v", err) } @@ -68,7 +63,7 @@ func main() { server := handlers.NewServer(queries, publisher, rpcClient) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production + AllowOrigins: []string{AppConfig.ServiceAddress}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, @@ -78,7 +73,7 @@ func main() { oapi.RegisterHandlers(r, oapi.NewStrictHandler( server, - // сюда можно добавить middlewares, если нужно + []oapi.StrictMiddlewareFunc{}, )) diff --git a/modules/backend/middlewares/access.go b/modules/backend/middlewares/access.go new file mode 100644 index 0000000..73200e8 --- /dev/null +++ b/modules/backend/middlewares/access.go @@ -0,0 +1,109 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// ctxKey — приватный тип для ключа контекста +type ctxKey struct{} + +// ginContextKey — уникальный ключ для хранения *gin.Context +var ginContextKey = &ctxKey{} + +// GinContextToContext сохраняет *gin.Context в context.Context запроса +func GinContextToContext(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), ginContextKey, c) + c.Request = c.Request.WithContext(ctx) +} + +// GinContextFromContext извлекает *gin.Context из context.Context +func GinContextFromContext(ctx context.Context) (*gin.Context, bool) { + ginCtx, ok := ctx.Value(ginContextKey).(*gin.Context) + return ginCtx, ok +} + +func JWTAuthMiddleware(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + // 1. Получаем access_token из cookie + tokenStr, err := c.Cookie("access_token") + if err != nil { + abortWithJSON(c, http.StatusUnauthorized, "missing access_token cookie") + return + } + + // 2. Парсим токен с MapClaims + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if t.Method != jwt.SigningMethodHS256 { + return nil, errors.New("unexpected signing method: " + t.Method.Alg()) + } + return []byte(secret), nil // ← конвертируем string → []byte + }) + if err != nil { + abortWithJSON(c, http.StatusUnauthorized, "invalid token: "+err.Error()) + return + } + + // 3. Проверяем валидность + if !token.Valid { + abortWithJSON(c, http.StatusUnauthorized, "token is invalid") + return + } + + // 4. Извлекаем user_id из claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + abortWithJSON(c, http.StatusUnauthorized, "invalid claims format") + return + } + + userID, ok := claims["user_id"].(string) + if !ok || userID == "" { + abortWithJSON(c, http.StatusUnauthorized, "user_id claim missing or invalid") + return + } + + // 5. Сохраняем в контексте + c.Set("user_id", userID) + + // 6. Для oapi-codegen — кладём gin.Context в request context + GinContextToContext(c) + + c.Next() + } +} + +// Вспомогательные функции (без изменений) +func UserIDFromGin(c *gin.Context) (string, bool) { + id, exists := c.Get("user_id") + if !exists { + return "", false + } + if s, ok := id.(string); ok { + return s, true + } + return "", false +} + +func UserIDFromContext(ctx context.Context) (string, error) { + ginCtx, ok := GinContextFromContext(ctx) + if !ok { + return "", errors.New("gin context not found") + } + userID, ok := UserIDFromGin(ginCtx) + if !ok { + return "", errors.New("user_id not found in context") + } + return userID, nil +} + +func abortWithJSON(c *gin.Context, code int, message string) { + c.AbortWithStatusJSON(code, gin.H{ + "error": "unauthorized", + "message": message, + }) +} diff --git a/modules/backend/types.go b/modules/backend/types.go index 20d3158..c4f70ed 100644 --- a/modules/backend/types.go +++ b/modules/backend/types.go @@ -1,12 +1,10 @@ package main type Config struct { - Mode string - LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` -} - -type Item struct { - ID int `json:"id"` - Title string `json:"title"` - Description string `json:"description"` + Mode string + ServiceAddress string `toml:"ServiceAddress" env:"SERVICE_ADDRESS"` + DdUrl string `toml:"DbUrl" env:"DATABASE_URL"` + JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` + LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` + rmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` } From 066c44d08a13a5127340e9b116615e6786d3495d Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 07:35:49 +0300 Subject: [PATCH 09/20] fix: AllowOrigins --- modules/auth/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/auth/main.go b/modules/auth/main.go index ef9b977..7305b7d 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -44,8 +44,9 @@ func main() { server := handlers.NewServer(queries, AppConfig.JwtPrivateKey) + log.Info("allow origins:", AppConfig.ServiceAddress) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{AppConfig.ServiceAddress}, + AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, From 570be2a68b0fb246e5f7ce86745223b1a0da7924 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Thu, 4 Dec 2025 07:40:21 +0300 Subject: [PATCH 10/20] fix --- deploy/docker-compose.yml | 1 + modules/backend/main.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0ae97c6..1bd7f71 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -64,6 +64,7 @@ services: DATABASE_URL: ${DATABASE_URL} SERVICE_ADDRESS: ${SERVICE_ADDRESS} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} + RABBITMQ_URL: ${RABBITMQ_URL} ports: - "8082:8082" depends_on: diff --git a/modules/backend/main.go b/modules/backend/main.go index 0cffdcf..9dac2a6 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -63,7 +63,8 @@ func main() { server := handlers.NewServer(queries, publisher, rpcClient) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{AppConfig.ServiceAddress}, + // AllowOrigins: []string{AppConfig.ServiceAddress}, + AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, From b6cf5231369035e40f6e32023f2eede6fd6f886b Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Thu, 4 Dec 2025 07:43:37 +0300 Subject: [PATCH 11/20] fix --- deploy/docker-compose.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1bd7f71..82116eb 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -47,6 +47,9 @@ services: environment: LOG_LEVEL: ${LOG_LEVEL} DATABASE_URL: ${DATABASE_URL} + SERVICE_ADDRESS: ${SERVICE_ADDRESS} + RABBITMQ_URL: ${RABBITMQ_URL} + JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} ports: - "8080:8080" depends_on: @@ -64,7 +67,6 @@ services: DATABASE_URL: ${DATABASE_URL} SERVICE_ADDRESS: ${SERVICE_ADDRESS} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} - RABBITMQ_URL: ${RABBITMQ_URL} ports: - "8082:8082" depends_on: From e12dff3455c25c067df42af384ea9a6e82e393df Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 07:59:32 +0300 Subject: [PATCH 12/20] fix: cicd env fix --- .forgejo/workflows/build-and-deploy.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index 3c473d2..dde9392 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -111,6 +111,11 @@ jobs: POSTGRES_VERSION: 18 LOG_LEVEL: ${{ vars.LOG_LEVEL }} DATABASE_URL: ${{ secrets.DATABASE_URL }} + SERVICE_ADDRESS: ${{ vars.SERVICE_ADDRESS }} + RABBITMQ_URL: ${{ secrets.RABBITMQ_URL }} + JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }} + RABBITMQ_DEFAULT_USER: ${{ secrets.RABBITMQ_USER }} + RABBITMQ_DEFAULT_PASS: ${{ secrets.RABBITMQ_PASSWORD }} steps: - name: Checkout code From 85a3c3ef107f9cbc4a80bef13861df559f8f2695 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 08:11:51 +0300 Subject: [PATCH 13/20] fix: backend config --- modules/backend/main.go | 2 +- modules/backend/types.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/backend/main.go b/modules/backend/main.go index 9dac2a6..37dcc7b 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -51,7 +51,7 @@ func main() { queries := sqlc.New(pool) - rmqConn, err := amqp091.Dial(AppConfig.rmqURL) + rmqConn, err := amqp091.Dial(AppConfig.RmqURL) if err != nil { log.Fatalf("Failed to connect to RabbitMQ: %v", err) } diff --git a/modules/backend/types.go b/modules/backend/types.go index c4f70ed..a069307 100644 --- a/modules/backend/types.go +++ b/modules/backend/types.go @@ -6,5 +6,5 @@ type Config struct { DdUrl string `toml:"DbUrl" env:"DATABASE_URL"` JwtPrivateKey string `toml:"JwtPrivateKey" env:"JWT_PRIVATE_KEY"` LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` - rmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` + RmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` } From 79a716cf550a96d3a9851932116c9d8358972fef Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 08:27:22 +0300 Subject: [PATCH 14/20] fix: use []byte for jwt key --- modules/auth/handlers/handlers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 09907bc..03df151 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -53,7 +53,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken "exp": time.Now().Add(15 * time.Minute).Unix(), } at := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) - accessToken, err = at.SignedString(s.JwtPrivateKey) + accessToken, err = at.SignedString([]byte(s.JwtPrivateKey)) if err != nil { return "", "", "", err } @@ -63,7 +63,7 @@ func (s Server) generateTokens(userID string) (accessToken string, refreshToken "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), } rt := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) - refreshToken, err = rt.SignedString(s.JwtPrivateKey) + refreshToken, err = rt.SignedString([]byte(s.JwtPrivateKey)) if err != nil { return "", "", "", err } From 3be58457aa82ab7c2017ed42dd526636f8a870b3 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 08:44:26 +0300 Subject: [PATCH 15/20] fix(front): CookiesProvider --- modules/frontend/src/main.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/frontend/src/main.tsx b/modules/frontend/src/main.tsx index bef5202..c225a33 100644 --- a/modules/frontend/src/main.tsx +++ b/modules/frontend/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { CookiesProvider } from 'react-cookie' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) From 2f4f8164df2ed625c3e13f7a35ea3d17e47b2956 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 09:03:51 +0300 Subject: [PATCH 16/20] feat: CORS X-XSRF-TOKEN --- modules/backend/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backend/main.go b/modules/backend/main.go index 37dcc7b..24325eb 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -66,7 +66,7 @@ func main() { // AllowOrigins: []string{AppConfig.ServiceAddress}, AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "X-XSRF-TOKEN"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, MaxAge: 12 * time.Hour, From 475266eef6fd08b6448475ae77e3631aab836efc Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 09:04:37 +0300 Subject: [PATCH 17/20] fix: revert AllowOrigins --- modules/backend/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/backend/main.go b/modules/backend/main.go index 24325eb..b833cf9 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -63,8 +63,8 @@ func main() { server := handlers.NewServer(queries, publisher, rpcClient) r.Use(cors.New(cors.Config{ - // AllowOrigins: []string{AppConfig.ServiceAddress}, - AllowOrigins: []string{"*"}, + AllowOrigins: []string{AppConfig.ServiceAddress}, + // AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "X-XSRF-TOKEN"}, ExposeHeaders: []string{"Content-Length"}, From bd868bb724a7374f649779e5d48650155755f8c2 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 10:12:05 +0300 Subject: [PATCH 18/20] fix: reworked csrf --- api/_build/openapi.yaml | 54 ++++--------------- api/openapi.yaml | 2 + api/parameters/_index.yaml | 8 +-- api/parameters/access_token.yaml | 9 ---- api/parameters/xsrf_token_cookie.yaml | 11 ---- api/parameters/xsrf_token_header.yaml | 10 ---- api/paths/users-id-titles-id.yaml | 8 +-- api/paths/users-id.yaml | 8 ++- api/securitySchemes/_index.yaml | 11 ++++ modules/frontend/src/App.tsx | 4 ++ modules/frontend/src/api/index.ts | 3 -- .../frontend/src/api/models/accessToken.ts | 9 ---- modules/frontend/src/api/models/csrfToken.ts | 11 ---- .../src/api/models/csrfTokenHeader.ts | 10 ---- .../src/api/services/DefaultService.ts | 21 -------- .../TitleStatusControls.tsx | 10 ++-- 16 files changed, 39 insertions(+), 150 deletions(-) delete mode 100644 api/parameters/access_token.yaml delete mode 100644 api/parameters/xsrf_token_cookie.yaml delete mode 100644 api/parameters/xsrf_token_header.yaml create mode 100644 api/securitySchemes/_index.yaml delete mode 100644 modules/frontend/src/api/models/accessToken.ts delete mode 100644 modules/frontend/src/api/models/csrfToken.ts delete mode 100644 modules/frontend/src/api/models/csrfTokenHeader.ts diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 225e7cd..3cbb361 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -150,8 +150,6 @@ paths: description: User not found '500': description: Unknown server error - security: - - JwtAuthCookies: [] patch: operationId: updateUser summary: Partially update a user account @@ -160,7 +158,6 @@ paths: Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. parameters: - - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path description: User ID (primary key) @@ -229,7 +226,7 @@ paths: '500': description: Unknown server error security: - - JwtAuthCookies: [] + XsrfAuthHeader: [] '/users/{user_id}/titles': get: operationId: getUserTitles @@ -405,14 +402,11 @@ paths: description: User or title not found '500': description: Unknown server error - security: - - JwtAuthCookies: [] patch: operationId: updateUserTitle summary: Update a usertitle description: User updating title list of watched parameters: - - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -455,13 +449,12 @@ paths: '500': description: Internal server error security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] delete: operationId: deleteUserTitle summary: Delete a usertitle description: User deleting title from list of watched parameters: - - $ref: '#/components/parameters/csrfTokenHeader' - name: user_id in: path required: true @@ -486,42 +479,9 @@ paths: '500': description: Internal server error security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] components: parameters: - accessToken: - name: access_token - in: cookie - required: true - schema: - type: string - format: jwt - example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y - description: | - JWT access token. - csrfToken: - name: xsrf_token - in: cookie - required: true - schema: - type: string - pattern: '^[a-zA-Z0-9_-]{32,64}$' - example: abc123def456ghi789jkl012mno345pqr - description: | - Anti-CSRF token (Double Submit Cookie pattern). - Stored in non-HttpOnly cookie, readable by JavaScript. - Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). - csrfTokenHeader: - name: X-XSRF-TOKEN - in: header - required: true - schema: - type: string - pattern: '^[a-zA-Z0-9_-]{32,64}$' - description: | - Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - Required for all state-changing requests (POST/PUT/PATCH/DELETE). - example: abc123def456ghi789jkl012mno345pqr cursor: in: query name: cursor @@ -780,3 +740,11 @@ components: Review: type: object additionalProperties: true + securitySchemes: + XsrfAuthHeader: + type: apiKey + in: header + name: X-XSRF-TOKEN + description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). diff --git a/api/openapi.yaml b/api/openapi.yaml index 08a4d54..d84797f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -23,3 +23,5 @@ components: $ref: "./parameters/_index.yaml" schemas: $ref: "./schemas/_index.yaml" + securitySchemes: + $ref: "./securitySchemes/_index.yaml" \ No newline at end of file diff --git a/api/parameters/_index.yaml b/api/parameters/_index.yaml index d2e12a8..6249e7d 100644 --- a/api/parameters/_index.yaml +++ b/api/parameters/_index.yaml @@ -1,10 +1,4 @@ cursor: $ref: "./cursor.yaml" title_sort: - $ref: "./title_sort.yaml" -accessToken: - $ref: "./access_token.yaml" -csrfToken: - $ref: "./xsrf_token_cookie.yaml" -csrfTokenHeader: - $ref: "./xsrf_token_header.yaml" \ No newline at end of file + $ref: "./title_sort.yaml" \ No newline at end of file diff --git a/api/parameters/access_token.yaml b/api/parameters/access_token.yaml deleted file mode 100644 index a7e727e..0000000 --- a/api/parameters/access_token.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: access_token -in: cookie -required: true -schema: - type: string - format: jwt -example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.x.y" -description: | - JWT access token. diff --git a/api/parameters/xsrf_token_cookie.yaml b/api/parameters/xsrf_token_cookie.yaml deleted file mode 100644 index 37041e0..0000000 --- a/api/parameters/xsrf_token_cookie.yaml +++ /dev/null @@ -1,11 +0,0 @@ -name: xsrf_token -in: cookie -required: true -schema: - type: string - pattern: "^[a-zA-Z0-9_-]{32,64}$" -example: "abc123def456ghi789jkl012mno345pqr" -description: | - Anti-CSRF token (Double Submit Cookie pattern). - Stored in non-HttpOnly cookie, readable by JavaScript. - Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). \ No newline at end of file diff --git a/api/parameters/xsrf_token_header.yaml b/api/parameters/xsrf_token_header.yaml deleted file mode 100644 index ac14dc1..0000000 --- a/api/parameters/xsrf_token_header.yaml +++ /dev/null @@ -1,10 +0,0 @@ -name: X-XSRF-TOKEN -in: header -required: true -schema: - type: string - pattern: "^[a-zA-Z0-9_-]{32,64}$" -description: | - Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - Required for all state-changing requests (POST/PUT/PATCH/DELETE). -example: "abc123def456ghi789jkl012mno345pqr" \ No newline at end of file diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml index b56d07a..1da2b81 100644 --- a/api/paths/users-id-titles-id.yaml +++ b/api/paths/users-id-titles-id.yaml @@ -1,8 +1,6 @@ get: summary: Get user title operationId: getUserTitle - security: - - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -37,9 +35,8 @@ patch: description: User updating title list of watched operationId: updateUserTitle security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] parameters: - - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true @@ -87,9 +84,8 @@ delete: description: User deleting title from list of watched operationId: deleteUserTitle security: - - JwtAuthCookies: [] + - XsrfAuthHeader: [] parameters: - - $ref: '../parameters/xsrf_token_header.yaml' - in: path name: user_id required: true diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index abb170e..5e9e69d 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -1,8 +1,6 @@ get: summary: Get user info operationId: getUsersId - security: - - JwtAuthCookies: [] parameters: - in: path name: user_id @@ -30,15 +28,15 @@ get: patch: summary: Partially update a user account - security: - - JwtAuthCookies: [] description: | Update selected user profile fields (excluding password). Password updates must be done via the dedicated auth-service (`/auth/`). Fields not provided in the request body remain unchanged. operationId: updateUser + security: + XsrfAuthHeader: [] parameters: - - $ref: '../parameters/xsrf_token_header.yaml' + # - $ref: '../parameters/xsrf_token_header.yaml' - name: user_id in: path required: true diff --git a/api/securitySchemes/_index.yaml b/api/securitySchemes/_index.yaml new file mode 100644 index 0000000..ecc0ff6 --- /dev/null +++ b/api/securitySchemes/_index.yaml @@ -0,0 +1,11 @@ +# accessToken: +# $ref: "./access_token.yaml" +# csrfToken: +# $ref: "./xsrf_token_cookie.yaml" +XsrfAuthHeader: + type: apiKey + in: header + name: X-XSRF-TOKEN + description: | + Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. + Required for all state-changing requests (POST/PUT/PATCH/DELETE). \ No newline at end of file diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 95b59e3..5ff2b32 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -6,6 +6,10 @@ import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; +import { OpenAPI } from "./api"; + +OpenAPI.WITH_CREDENTIALS = true + const App: React.FC = () => { const username = localStorage.getItem("username") || undefined; const userId = localStorage.getItem("userId"); diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index c1e9cdc..9013fc7 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -7,9 +7,6 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; -export type { accessToken } from './models/accessToken'; -export type { csrfToken } from './models/csrfToken'; -export type { csrfTokenHeader } from './models/csrfTokenHeader'; export type { cursor } from './models/cursor'; export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; diff --git a/modules/frontend/src/api/models/accessToken.ts b/modules/frontend/src/api/models/accessToken.ts deleted file mode 100644 index adc8fb7..0000000 --- a/modules/frontend/src/api/models/accessToken.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * JWT access token. - * - */ -export type accessToken = string; diff --git a/modules/frontend/src/api/models/csrfToken.ts b/modules/frontend/src/api/models/csrfToken.ts deleted file mode 100644 index 4af805b..0000000 --- a/modules/frontend/src/api/models/csrfToken.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Anti-CSRF token (Double Submit Cookie pattern). - * Stored in non-HttpOnly cookie, readable by JavaScript. - * Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). - * - */ -export type csrfToken = string; diff --git a/modules/frontend/src/api/models/csrfTokenHeader.ts b/modules/frontend/src/api/models/csrfTokenHeader.ts deleted file mode 100644 index 354c8a3..0000000 --- a/modules/frontend/src/api/models/csrfTokenHeader.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * - */ -export type csrfTokenHeader = string; diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index f3d803d..6898c46 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -135,16 +135,12 @@ export class DefaultService { * Password updates must be done via the dedicated auth-service (`/auth/`). * Fields not provided in the request body remain unchanged. * - * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * * @param userId User ID (primary key) * @param requestBody * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). * @throws ApiError */ public static updateUser( - xXsrfToken: string, userId: number, requestBody: { /** @@ -175,9 +171,6 @@ export class DefaultService { path: { 'user_id': userId, }, - headers: { - 'X-XSRF-TOKEN': xXsrfToken, - }, body: requestBody, mediaType: 'application/json', errors: { @@ -316,9 +309,6 @@ export class DefaultService { /** * Update a usertitle * User updating title list of watched - * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * * @param userId * @param titleId * @param requestBody @@ -326,7 +316,6 @@ export class DefaultService { * @throws ApiError */ public static updateUserTitle( - xXsrfToken: string, userId: number, titleId: number, requestBody: { @@ -341,9 +330,6 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, - headers: { - 'X-XSRF-TOKEN': xXsrfToken, - }, body: requestBody, mediaType: 'application/json', errors: { @@ -358,16 +344,12 @@ export class DefaultService { /** * Delete a usertitle * User deleting title from list of watched - * @param xXsrfToken Anti-CSRF token. Must match the `XSRF-TOKEN` cookie. - * Required for all state-changing requests (POST/PUT/PATCH/DELETE). - * * @param userId * @param titleId * @returns any Title successfully deleted * @throws ApiError */ public static deleteUserTitle( - xXsrfToken: string, userId: number, titleId: number, ): CancelablePromise { @@ -378,9 +360,6 @@ export class DefaultService { 'user_id': userId, 'title_id': titleId, }, - headers: { - 'X-XSRF-TOKEN': xXsrfToken, - }, errors: { 401: `Unauthorized — missing or invalid auth token`, 403: `Forbidden — user not allowed to delete title`, diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx index 4fb535a..cc9f80d 100644 --- a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { DefaultService } from "../../api"; import type { UserTitleStatus } from "../../api"; -import { useCookies } from 'react-cookie'; +// import { useCookies } from 'react-cookie'; import { ClockIcon, @@ -19,8 +19,8 @@ const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: s ]; export function TitleStatusControls({ titleId }: { titleId: number }) { - const [cookies] = useCookies(['xsrf_token']); - const xsrfToken = cookies['xsrf_token'] || null; + // const [cookies] = useCookies(['xsrf_token']); + // const xsrfToken = cookies['xsrf_token'] || null; const [currentStatus, setCurrentStatus] = useState(null); const [loading, setLoading] = useState(false); @@ -46,7 +46,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { try { // 1) Если кликнули на текущий статус — DELETE if (currentStatus === status) { - await DefaultService.deleteUserTitle(xsrfToken, userId, titleId); + await DefaultService.deleteUserTitle(userId, titleId); setCurrentStatus(null); return; } @@ -61,7 +61,7 @@ export function TitleStatusControls({ titleId }: { titleId: number }) { setCurrentStatus(added.status); } else { // уже есть запись — PATCH - const updated = await DefaultService.updateUserTitle(xsrfToken, userId, titleId, { status }); + const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); setCurrentStatus(updated.status); } } finally { From 128a33824a2bb6d4b6a9a9e3168f8770e8e420c6 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 10:18:37 +0300 Subject: [PATCH 19/20] feat: regenerated go oapi --- api/_build/openapi.yaml | 2 +- api/api.gen.go | 71 ++++++----------------------------------- api/paths/users-id.yaml | 2 +- 3 files changed, 11 insertions(+), 64 deletions(-) diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 3cbb361..e096beb 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -226,7 +226,7 @@ paths: '500': description: Unknown server error security: - XsrfAuthHeader: [] + - XsrfAuthHeader: [] '/users/{user_id}/titles': get: operationId: getUserTitles diff --git a/api/api.gen.go b/api/api.gen.go index 62450e0..459a3e4 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -18,6 +18,7 @@ import ( const ( JwtAuthCookiesScopes = "JwtAuthCookies.Scopes" + XsrfAuthHeaderScopes = "XsrfAuthHeader.Scopes" ) // Defines values for ReleaseSeason. @@ -174,12 +175,6 @@ type UserTitleMini struct { // UserTitleStatus User's title status type UserTitleStatus string -// AccessToken defines model for accessToken. -type AccessToken = string - -// CsrfToken defines model for csrfToken. -type CsrfToken = string - // Cursor defines model for cursor. type Cursor = string @@ -229,17 +224,6 @@ type UpdateUserJSONBody struct { UserDesc *string `json:"user_desc,omitempty"` } -// UpdateUserParams defines parameters for UpdateUser. -type UpdateUserParams struct { - // AccessToken JWT access token. - AccessToken AccessToken `form:"access_token" json:"access_token"` - - // XSRFTOKEN Anti-CSRF token (Double Submit Cookie pattern). - // Stored in non-HttpOnly cookie, readable by JavaScript. - // Must be echoed in `X-XSRF-TOKEN` header for state-changing requests (POST/PUT/PATCH/DELETE). - XSRFTOKEN CsrfToken `form:"XSRF-TOKEN" json:"XSRF-TOKEN"` -} - // GetUserTitlesParams defines parameters for GetUserTitles. type GetUserTitlesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -297,7 +281,7 @@ type ServerInterface interface { GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) // Partially update a user account // (PATCH /users/{user_id}) - UpdateUser(c *gin.Context, userId int64, params UpdateUserParams) + UpdateUser(c *gin.Context, userId int64) // Get user titles // (GET /users/{user_id}/titles) GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) @@ -524,46 +508,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { return } - c.Set(JwtAuthCookiesScopes, []string{}) - - // Parameter object where we will unmarshal all parameters from the context - var params UpdateUserParams - - { - var cookie string - - if cookie, err = c.Cookie("access_token"); err == nil { - var value AccessToken - err = runtime.BindStyledParameterWithOptions("simple", "access_token", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter access_token: %w", err), http.StatusBadRequest) - return - } - params.AccessToken = value - - } else { - siw.ErrorHandler(c, fmt.Errorf("Query argument access_token is required, but not found"), http.StatusBadRequest) - return - } - } - - { - var cookie string - - if cookie, err = c.Cookie("XSRF-TOKEN"); err == nil { - var value CsrfToken - err = runtime.BindStyledParameterWithOptions("simple", "XSRF-TOKEN", cookie, &value, runtime.BindStyledParameterOptions{Explode: true, Required: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter XSRF-TOKEN: %w", err), http.StatusBadRequest) - return - } - params.XSRFTOKEN = value - - } else { - siw.ErrorHandler(c, fmt.Errorf("Query argument XSRF-TOKEN is required, but not found"), http.StatusBadRequest) - return - } - } + c.Set(XsrfAuthHeaderScopes, []string{}) for _, middleware := range siw.HandlerMiddlewares { middleware(c) @@ -572,7 +517,7 @@ func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { } } - siw.Handler.UpdateUser(c, userId, params) + siw.Handler.UpdateUser(c, userId) } // GetUserTitles operation middleware @@ -745,6 +690,8 @@ func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { return } + c.Set(XsrfAuthHeaderScopes, []string{}) + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -811,6 +758,8 @@ func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { return } + c.Set(XsrfAuthHeaderScopes, []string{}) + for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -999,7 +948,6 @@ func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWri type UpdateUserRequestObject struct { UserId int64 `json:"user_id"` - Params UpdateUserParams Body *UpdateUserJSONRequestBody } @@ -1476,11 +1424,10 @@ func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetU } // UpdateUser operation middleware -func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64, params UpdateUserParams) { +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { var request UpdateUserRequestObject request.UserId = userId - request.Params = params var body UpdateUserJSONRequestBody if err := ctx.ShouldBindJSON(&body); err != nil { diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 5e9e69d..701df6b 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -34,7 +34,7 @@ patch: Fields not provided in the request body remain unchanged. operationId: updateUser security: - XsrfAuthHeader: [] + - XsrfAuthHeader: [] parameters: # - $ref: '../parameters/xsrf_token_header.yaml' - name: user_id From fc2fa6b9786808f0164c0d6f7c8c7bb92b545675 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 11:52:18 +0300 Subject: [PATCH 20/20] feat: oapi credenials include --- modules/frontend/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 5ff2b32..67336c1 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { Header } from "./components/Header/Header"; import { OpenAPI } from "./api"; OpenAPI.WITH_CREDENTIALS = true +OpenAPI.CREDENTIALS = 'include' const App: React.FC = () => { const username = localStorage.getItem("username") || undefined;