diff --git a/backend/internal/dto/claims.go b/backend/internal/dto/claims.go
new file mode 100644
index 0000000..87f9bb5
--- /dev/null
+++ b/backend/internal/dto/claims.go
@@ -0,0 +1,32 @@
+// Copyright (c) 2025 Nikolai Papin
+//
+// This file is part of Easywish
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+// the GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package dto
+
+import (
+ "easywish/internal/utils/enums"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+type UserClaims struct {
+ Username string `json:"username"`
+ Role enums.Role `json:"role"`
+ Type enums.JwtTokenType `json:"type"`
+ Session string `json:"session"`
+ jwt.RegisteredClaims
+}
diff --git a/backend/internal/dto/request.go b/backend/internal/dto/request.go
new file mode 100644
index 0000000..17672db
--- /dev/null
+++ b/backend/internal/dto/request.go
@@ -0,0 +1,23 @@
+// Copyright (c) 2025 Nikolai Papin
+//
+// This file is part of Easywish
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+// the GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package dto
+
+type Request[T any] struct {
+ User ClientInfo
+ Body T
+}
diff --git a/backend/internal/dto/sessionInfo.go b/backend/internal/dto/sessionInfo.go
new file mode 100644
index 0000000..8de7a86
--- /dev/null
+++ b/backend/internal/dto/sessionInfo.go
@@ -0,0 +1,27 @@
+
+// Copyright (c) 2025 Nikolai Papin
+//
+// This file is part of Easywish
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+// the GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package dto
+
+import "easywish/internal/utils/enums"
+
+type SessionInfo struct {
+ Username string
+ Session string
+ Role enums.Role
+}
diff --git a/backend/internal/dto/userInfo.go b/backend/internal/dto/userInfo.go
new file mode 100644
index 0000000..42ba7b0
--- /dev/null
+++ b/backend/internal/dto/userInfo.go
@@ -0,0 +1,24 @@
+// Copyright (c) 2025 Nikolai Papin
+//
+// This file is part of Easywish
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+// the GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package dto
+
+type ClientInfo struct {
+ SessionInfo
+ IP string
+ UserAgent string
+}
diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go
index 284381b..358d76f 100644
--- a/backend/internal/middleware/auth.go
+++ b/backend/internal/middleware/auth.go
@@ -21,6 +21,7 @@ import (
"context"
"easywish/config"
"easywish/internal/database"
+ "easywish/internal/dto"
"easywish/internal/utils/enums"
"errors"
"fmt"
@@ -34,14 +35,6 @@ import (
"go.uber.org/zap"
)
-type Claims struct {
- Username string `json:"username"`
- Role enums.Role `json:"role"`
- Type enums.JwtTokenType `json:"type"`
- Session string `json:"session"`
- jwt.RegisteredClaims
-}
-
// XXX: cluttered; move cache & database check to auth service
func AuthMiddleware(log *zap.Logger, dbctx database.DbContext, redisClient *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
@@ -49,8 +42,12 @@ func AuthMiddleware(log *zap.Logger, dbctx database.DbContext, redisClient *redi
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
- c.Set("username", nil)
- c.Set("role", enums.GuestRole)
+
+ c.Set("session_info", dto.SessionInfo{
+ Username: "",
+ Session: "",
+ Role: enums.GuestRole},
+ )
c.Next()
return
}
@@ -59,7 +56,7 @@ func AuthMiddleware(log *zap.Logger, dbctx database.DbContext, redisClient *redi
token, err := jwt.ParseWithClaims(
tokenString,
- &Claims{},
+ &dto.UserClaims{},
func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
@@ -77,7 +74,7 @@ func AuthMiddleware(log *zap.Logger, dbctx database.DbContext, redisClient *redi
return
}
- if claims, ok := token.Claims.(*Claims); ok && token.Valid {
+ if claims, ok := token.Claims.(*dto.UserClaims); ok && token.Valid {
if claims.Type != enums.JwtAccessTokenType {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Not an access token"})
@@ -142,8 +139,12 @@ func AuthMiddleware(log *zap.Logger, dbctx database.DbContext, redisClient *redi
return
}
- c.Set("username", claims.Username)
- c.Set("role", claims.Role)
+ c.Set("session_info", dto.SessionInfo{
+ Username: claims.Username,
+ Session: claims.Session,
+ Role: claims.Role,
+ })
+
c.Next()
} else {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid claims"})
diff --git a/backend/internal/middleware/request.go b/backend/internal/middleware/request.go
index 36ef57b..a729644 100644
--- a/backend/internal/middleware/request.go
+++ b/backend/internal/middleware/request.go
@@ -18,6 +18,7 @@
package middleware
import (
+ "easywish/internal/dto"
"easywish/internal/utils/enums"
"easywish/internal/validation"
"fmt"
@@ -27,58 +28,51 @@ import (
"github.com/go-playground/validator/v10"
)
-type UserInfo struct {
- Username string
- Role enums.Role
-}
-
-type Request[T any] struct {
- User UserInfo
- Body T
-}
-
const requestKey = "request"
-func UserInfoFromContext(c *gin.Context) (*UserInfo, bool) {
+func ClientInfoFromContext(c *gin.Context) (*dto.ClientInfo, bool) {
- var username any
- var role any
var ok bool
- username, ok = c.Get("username") ; if !ok {
- return &UserInfo{Username: "", Role: enums.GuestRole}, true
- }
+ ip := c.ClientIP()
+ userAgent := c.Request.UserAgent()
- role, ok = c.Get("role"); if !ok {
+ sessionInfoFromCtx, ok := c.Get("session_info"); if !ok {
return nil, false
}
- if username == nil {
- return &UserInfo{Username: "", Role: enums.GuestRole}, true
+ sessionInfo := sessionInfoFromCtx.(dto.SessionInfo)
+
+ if sessionInfo.Username == "" {
+ return &dto.ClientInfo{
+ SessionInfo: sessionInfo,
+ IP: ip,
+ UserAgent: userAgent,
+ }, true
}
- if role == nil {
- return nil, false
- }
-
- return &UserInfo{Username: username.(string), Role: role.(enums.Role)}, true
+ return &dto.ClientInfo{
+ SessionInfo: sessionInfo,
+ IP: ip,
+ UserAgent: userAgent,
+ }, true
}
-func RequestFromContext[T any](c *gin.Context) Request[T] {
- return c.Value(requestKey).(Request[T])
+func RequestFromContext[T any](c *gin.Context) dto.Request[T] {
+ return c.Value(requestKey).(dto.Request[T])
}
func RequestMiddleware[T any](role enums.Role) gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
- userInfo, ok := UserInfoFromContext(c)
+ clientInfo, ok := ClientInfoFromContext(c)
if !ok {
c.Status(http.StatusUnauthorized)
return
}
- if userInfo.Role < role {
+ if clientInfo.Role < role {
c.Status(http.StatusForbidden)
return
}
@@ -99,8 +93,8 @@ func RequestMiddleware[T any](role enums.Role) gin.HandlerFunc {
return
}
- request := Request[T]{
- User: *userInfo,
+ request := dto.Request[T]{
+ User: *clientInfo,
Body: body,
}
diff --git a/backend/internal/services/auth.go b/backend/internal/services/auth.go
index 5e5c21e..f14d6fe 100644
--- a/backend/internal/services/auth.go
+++ b/backend/internal/services/auth.go
@@ -21,6 +21,7 @@ import (
"context"
"easywish/config"
"easywish/internal/database"
+ "easywish/internal/dto"
errs "easywish/internal/errors"
"easywish/internal/models"
"easywish/internal/utils"
@@ -30,6 +31,7 @@ import (
"time"
"github.com/go-redis/redis/v8"
+ "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
@@ -43,6 +45,7 @@ type AuthService interface {
Refresh(request models.RefreshRequest) (*models.RefreshResponse, error)
PasswordResetBegin(request models.PasswordResetBeginRequest) (bool, error)
PasswordResetComplete(request models.PasswordResetCompleteRequest) (*models.PasswordResetCompleteResponse, error)
+ ValidateToken(token string, tokenType enums.JwtTokenType) (*dto.SessionInfo, error)
}
type authServiceImpl struct {
@@ -505,6 +508,100 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo
}
func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) {
+
+ var err error
+
+ token, err := jwt.ParseWithClaims(
+ request.RefreshToken,
+ &dto.UserClaims{},
+ func(token *jwt.Token) (any, error) {
+ if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+ }
+ return []byte(config.GetConfig().JwtSecret), nil
+ },
+ )
+
+ if err != nil {
+ if errors.Is(err, jwt.ErrTokenExpired) {
+ // AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token expired"})
+ } else {
+ // c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
+ }
+ return nil, errs.ErrUnauthorized
+ }
+
+ if claims, ok := token.Claims.(*dto.UserClaims); ok && token.Valid {
+
+ if claims.Type != enums.JwtAccessTokenType {
+ // c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Not an access token"})
+ return nil, errs.ErrUnauthorized
+ }
+
+ ctx := context.TODO()
+ isTerminated, redisErr := a.redis.Get(ctx, fmt.Sprintf("session::%s::is_terminated", claims.Session)).Bool()
+ if redisErr != nil && redisErr != redis.Nil {
+ a.log.Error(
+ "Failed to lookup cache to check whether session is not terminated",
+ zap.Error(redisErr))
+ // c.AbortWithStatus(http.StatusInternalServerError)
+ return nil, errs.ErrServerError
+ }
+
+ // Cache if nil
+ if redisErr == redis.Nil {
+ db := database.NewDbHelper(a.dbctx)
+
+ session, err := db.Queries.GetSessionByGuid(db.CTX, claims.Session)
+ if err != nil {
+
+ if errors.Is(err, pgx.ErrNoRows) {
+ a.log.Warn(
+ "Session does not exist or was deleted",
+ zap.String("session", claims.Session))
+ // c.AbortWithStatus(http.StatusUnauthorized)
+ return nil, errs.ErrUnauthorized
+ }
+
+ a.log.Error(
+ "Failed to lookup session in database",
+ zap.String("session", claims.Session),
+ zap.Error(err))
+ // c.AbortWithStatus(http.StatusInternalServerError)
+ return nil, errs.ErrServerError
+ }
+
+ if err := a.redis.Set(
+ ctx,
+ fmt.Sprintf("session::%s::is_terminated", claims.Session),
+ session.Terminated,
+ time.Duration(8 * time.Hour), // XXX: magic number
+ ).Err(); err != nil {
+ a.log.Error(
+ "Failed to cache session's is_terminated state",
+ zap.String("session", claims.Session),
+ zap.Error(err))
+ // c.AbortWithStatus(http.StatusInternalServerError)
+ return nil, errs.ErrServerError
+ }
+
+ isTerminated = *session.Terminated
+ }
+
+ if isTerminated {
+ a.log.Warn(
+ "Attempt to access resource from a terminated session",
+ zap.String("session", claims.Session))
+ // c.AbortWithStatus(http.StatusUnauthorized)
+ return nil, errs.ErrUnauthorized
+ }
+ }
+ // TODO: generate some tokens
+
+ return nil, errs.ErrNotImplemented
+}
+
+func (a *authServiceImpl) ValidateToken(token string, tokenType enums.JwtTokenType) (*dto.SessionInfo, error) {
return nil, errs.ErrNotImplemented
}
diff --git a/backend/internal/utils/request.go b/backend/internal/utils/request.go
index 876c823..81e3195 100644
--- a/backend/internal/utils/request.go
+++ b/backend/internal/utils/request.go
@@ -1,32 +1,31 @@
// Copyright (c) 2025 Nikolai Papin
-//
+//
// This file is part of Easywish
-//
+//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
-//
+//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
-//
+//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
package utils
import (
- "easywish/internal/middleware"
-
+ "easywish/internal/dto"
"github.com/gin-gonic/gin"
)
-func GetRequest[T any](c *gin.Context) (*middleware.Request[T], bool) {
+func GetRequest[T any](c *gin.Context) (*dto.Request[T], bool) {
req, ok := c.Get("request")
- request := req.(middleware.Request[T])
+ request := req.(dto.Request[T])
if !ok {
return nil, false
}