From 6995ce58f6d8f588f235cbaf985b7b82e76ecda1 Mon Sep 17 00:00:00 2001 From: Iron_Felix Date: Thu, 4 Dec 2025 06:13:03 +0300 Subject: [PATCH 1/3] 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 b79a6b9117e4a7384398541105c801e81e0351d2 Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 06:29:20 +0300 Subject: [PATCH 2/3] 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 1bbfa338d92b4122a658bb3487c98666aae4652a Mon Sep 17 00:00:00 2001 From: nihonium Date: Thu, 4 Dec 2025 07:17:31 +0300 Subject: [PATCH 3/3] 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) {