From a822d8c3b6f412c0b34dcf3b5c26a42b59f5ee7a Mon Sep 17 00:00:00 2001 From: Mephimeow Date: Fri, 12 Jun 2026 10:01:21 +0000 Subject: [PATCH] added refresh tocken --- .env.example | 3 +- cmd/backend/main.go | 3 +- docs/docs.go | 62 ++++++++++++++++++++++++ docs/swagger.json | 62 ++++++++++++++++++++++++ docs/swagger.yaml | 41 ++++++++++++++++ internal/auth/handler.go | 30 ++++++++++++ internal/auth/models.go | 17 ++++++- internal/auth/repository.go | 53 +++++++++++++++++--- internal/auth/service.go | 96 +++++++++++++++++++++++++++++++------ internal/config/config.go | 30 ++++++++---- 10 files changed, 362 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index a0662e6..0c8e13c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ SERVER_PORT=8080 MONGO_URI=mongodb://localhost:27017 MONGO_DB=aegisguard -JWT_SECRET=bebebe_rewritemeeee +JWT_SECRET=bebebe_rewritemeeee_openssl_rand JWT_EXPIRATION=24h +JWT_REFRESH_EXPIRATION=168h diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 8a1fee1..675545b 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -56,7 +56,7 @@ func main() { log.Printf("warning: failed to ensure indexes: %v", err) } - svc := auth.NewService(repo, cfg.JWTSecret, cfg.JWTExpiration) + svc := auth.NewService(repo, cfg.JWTSecret, cfg.JWTExpiration, cfg.JWTRefreshExpiration) handler := auth.NewHandler(svc) gin.SetMode(gin.ReleaseMode) @@ -76,6 +76,7 @@ func main() { { api.POST("/register", handler.Register) api.POST("/login", handler.Login) + api.POST("/refresh", handler.Refresh) api.GET("/me", auth.AuthMiddleware([]byte(cfg.JWTSecret)), handler.Me) } diff --git a/docs/docs.go b/docs/docs.go index 2fc0495..29eb1e3 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -95,6 +95,52 @@ const docTemplate = `{ } } }, + "/api/auth/refresh": { + "post": { + "description": "Get a new access token using a refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh epta token", + "parameters": [ + { + "description": "Refresh token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_auth.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, "/api/auth/register": { "post": { "description": "Create user account with username, email, password", @@ -146,6 +192,10 @@ const docTemplate = `{ "internal_auth.AuthResponse": { "type": "object", "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + }, "token": { "type": "string", "example": "eyJhbGciOiJIUzI1NiIs..." @@ -181,6 +231,18 @@ const docTemplate = `{ } } }, + "internal_auth.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, "internal_auth.RegisterRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 1ce4369..4395985 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -90,6 +90,52 @@ } } }, + "/api/auth/refresh": { + "post": { + "description": "Get a new access token using a refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh epta token", + "parameters": [ + { + "description": "Refresh token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_auth.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, "/api/auth/register": { "post": { "description": "Create user account with username, email, password", @@ -141,6 +187,10 @@ "internal_auth.AuthResponse": { "type": "object", "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + }, "token": { "type": "string", "example": "eyJhbGciOiJIUzI1NiIs..." @@ -176,6 +226,18 @@ } } }, + "internal_auth.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, "internal_auth.RegisterRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5b882f8..49ef4f7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,6 +1,9 @@ definitions: internal_auth.AuthResponse: properties: + refresh_token: + example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4= + type: string token: example: eyJhbGciOiJIUzI1NiIs... type: string @@ -25,6 +28,14 @@ definitions: - email - password type: object + internal_auth.RefreshRequest: + properties: + refresh_token: + example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4= + type: string + required: + - refresh_token + type: object internal_auth.RegisterRequest: properties: email: @@ -117,6 +128,36 @@ paths: summary: Epta get current user tags: - auth + /api/auth/refresh: + post: + consumes: + - application/json + description: Get a new access token using a refresh token + parameters: + - description: Refresh token + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_auth.RefreshRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_auth.AuthResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + summary: Refresh epta token + tags: + - auth /api/auth/register: post: consumes: diff --git a/internal/auth/handler.go b/internal/auth/handler.go index e26b0bb..039c2fb 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -71,6 +71,36 @@ func (h *Handler) Login(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// @Summary Refresh epta token +// @Description Get a new access token using a refresh token +// @Tags auth +// @Accept json +// @Produce json +// @Param request body RefreshRequest true "Refresh token" +// @Success 200 {object} AuthResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Router /api/auth/refresh [post] +func (h *Handler) Refresh(c *gin.Context) { + var req RefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + resp, err := h.service.Refresh(c.Request.Context(), req.RefreshToken) + if err != nil { + if errors.Is(err, ErrInvalidRefresh) || errors.Is(err, ErrRefreshExpired) { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, resp) +} + // @Summary Epta get current user // @Description Get authenticated user's profile // @Tags auth diff --git a/internal/auth/models.go b/internal/auth/models.go index 6b11c3d..52a5c3d 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -26,8 +26,21 @@ type LoginRequest struct { } type AuthResponse struct { - Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."` - User UserPublic `json:"user"` + Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."` + RefreshToken string `json:"refresh_token" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="` + User UserPublic `json:"user"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="` +} + +type RefreshTokenDoc struct { + ID bson.ObjectID `json:"id" bson:"_id"` + UserID bson.ObjectID `json:"user_id" bson:"user_id"` + TokenHash string `json:"token_hash" bson:"token_hash"` + ExpiresAt time.Time `json:"expires_at" bson:"expires_at"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` } type UserPublic struct { diff --git a/internal/auth/repository.go b/internal/auth/repository.go index e9829ff..945db7f 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -10,33 +10,49 @@ import ( ) type Repository struct { - collection *mongo.Collection + usersCollection *mongo.Collection + refreshTokensCollection *mongo.Collection } func NewRepository(db *mongo.Database) *Repository { return &Repository{ - collection: db.Collection("users"), + usersCollection: db.Collection("users"), + refreshTokensCollection: db.Collection("refresh_tokens"), } } func (r *Repository) EnsureIndexes(ctx context.Context) error { - _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{ + _, err := r.usersCollection.Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "email", Value: 1}}, Options: options.Index().SetUnique(true), }) + if err != nil { + return err + } + + _, err = r.refreshTokensCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{{Key: "token_hash", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{{Key: "expires_at", Value: 1}}, + Options: options.Index().SetExpireAfterSeconds(0), + }, + }) return err } -func (r *Repository) Create(ctx context.Context, user *User) error { +func (r *Repository) CreateUser(ctx context.Context, user *User) error { user.ID = bson.NewObjectID() user.CreatedAt = time.Now().UTC() - _, err := r.collection.InsertOne(ctx, user) + _, err := r.usersCollection.InsertOne(ctx, user) return err } func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, error) { var user User - err := r.collection.FindOne(ctx, bson.M{"email": email}).Decode(&user) + err := r.usersCollection.FindOne(ctx, bson.M{"email": email}).Decode(&user) if err != nil { return nil, err } @@ -45,9 +61,32 @@ func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, erro func (r *Repository) FindByID(ctx context.Context, id bson.ObjectID) (*User, error) { var user User - err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user) + err := r.usersCollection.FindOne(ctx, bson.M{"_id": id}).Decode(&user) if err != nil { return nil, err } return &user, nil } + +//Refresh + +func (r *Repository) CreateRefreshToken(ctx context.Context, doc *RefreshTokenDoc) error { + doc.ID = bson.NewObjectID() + doc.CreatedAt = time.Now().UTC() + _, err := r.refreshTokensCollection.InsertOne(ctx, doc) + return err +} + +func (r *Repository) FindRefreshTokenByHash(ctx context.Context, hash string) (*RefreshTokenDoc, error) { + var doc RefreshTokenDoc + err := r.refreshTokensCollection.FindOne(ctx, bson.M{"token_hash": hash}).Decode(&doc) + if err != nil { + return nil, err + } + return &doc, nil +} + +func (r *Repository) DeleteRefreshToken(ctx context.Context, id bson.ObjectID) error { + _, err := r.refreshTokensCollection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} diff --git a/internal/auth/service.go b/internal/auth/service.go index 7791b77..242a485 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -2,6 +2,9 @@ package auth import ( "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "errors" "fmt" "time" @@ -16,22 +19,67 @@ var ( ErrInvalidCreds = errors.New("invalid email or password") ErrUserNotFound = errors.New("user not found") ErrInvalidUserID = errors.New("invalid user ID") + ErrInvalidRefresh = errors.New("invalid refresh token") + ErrRefreshExpired = errors.New("refresh token expired") ) type Service struct { - repo *Repository - jwtSecret []byte - jwtExp time.Duration + repo *Repository + jwtSecret []byte + jwtExp time.Duration + refreshExp time.Duration } -func NewService(repo *Repository, jwtSecret string, jwtExp time.Duration) *Service { +func NewService(repo *Repository, jwtSecret string, jwtExp, refreshExp time.Duration) *Service { return &Service{ - repo: repo, - jwtSecret: []byte(jwtSecret), - jwtExp: jwtExp, + repo: repo, + jwtSecret: []byte(jwtSecret), + jwtExp: jwtExp, + refreshExp: refreshExp, } } +func sha256Hex(data string) string { + h := sha256.Sum256([]byte(data)) + return fmt.Sprintf("%x", h) +} + +func generateRandomToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func (s *Service) issueTokenPair(ctx context.Context, user *User) (*AuthResponse, error) { + accessToken, err := GenerateToken(user.ID.Hex(), user.Email, s.jwtSecret, s.jwtExp) + if err != nil { + return nil, fmt.Errorf("failed to generate access token: %w", err) + } + + rawRefresh, err := generateRandomToken() + if err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + + refreshDoc := &RefreshTokenDoc{ + UserID: user.ID, + TokenHash: sha256Hex(rawRefresh), + ExpiresAt: time.Now().UTC().Add(s.refreshExp), + } + + if err := s.repo.CreateRefreshToken(ctx, refreshDoc); err != nil { + return nil, fmt.Errorf("failed to store refresh token: %w", err) + } + + return &AuthResponse{ + Token: accessToken, + RefreshToken: rawRefresh, + User: NewUserPublic(user), + }, nil +} + func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPublic, error) { existing, err := s.repo.FindByEmail(ctx, req.Email) if err != nil && !errors.Is(err, mongo.ErrNoDocuments) { @@ -52,7 +100,7 @@ func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPubli PasswordHash: string(hash), } - if err := s.repo.Create(ctx, user); err != nil { + if err := s.repo.CreateUser(ctx, user); err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } @@ -73,15 +121,35 @@ func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponse, e return nil, ErrInvalidCreds } - token, err := GenerateToken(user.ID.Hex(), user.Email, s.jwtSecret, s.jwtExp) + return s.issueTokenPair(ctx, user) +} + +func (s *Service) Refresh(ctx context.Context, rawRefresh string) (*AuthResponse, error) { + hash := sha256Hex(rawRefresh) + + doc, err := s.repo.FindRefreshTokenByHash(ctx, hash) if err != nil { - return nil, fmt.Errorf("failed to generate token: %w", err) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrInvalidRefresh + } + return nil, fmt.Errorf("failed to find refresh token: %w", err) } - return &AuthResponse{ - Token: token, - User: NewUserPublic(user), - }, nil + if time.Now().UTC().After(doc.ExpiresAt) { + s.repo.DeleteRefreshToken(ctx, doc.ID) + return nil, ErrRefreshExpired + } + + if err := s.repo.DeleteRefreshToken(ctx, doc.ID); err != nil { + return nil, fmt.Errorf("failed to delete old refresh token: %w", err) + } + + user, err := s.repo.FindByID(ctx, doc.UserID) + if err != nil { + return nil, fmt.Errorf("failed to find user: %w", err) + } + + return s.issueTokenPair(ctx, user) } func (s *Service) GetUserByID(ctx context.Context, userID string) (*UserPublic, error) { diff --git a/internal/config/config.go b/internal/config/config.go index 59457b2..185cb30 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,22 +9,24 @@ import ( ) type Config struct { - ServerPort string - MongoURI string - MongoDB string - JWTSecret string - JWTExpiration time.Duration + ServerPort string + MongoURI string + MongoDB string + JWTSecret string + JWTExpiration time.Duration + JWTRefreshExpiration time.Duration } func Load() (*Config, error) { godotenv.Load() cfg := &Config{ - ServerPort: getEnv("SERVER_PORT", "8080"), - MongoURI: getEnv("MONGO_URI", "mongodb://localhost:27017"), - MongoDB: getEnv("MONGO_DB", "aegisguard"), - JWTSecret: getEnv("JWT_SECRET", ""), - JWTExpiration: 24 * time.Hour, + ServerPort: getEnv("SERVER_PORT", "8080"), + MongoURI: getEnv("MONGO_URI", "mongodb://localhost:27017"), + MongoDB: getEnv("MONGO_DB", "aegisguard"), + JWTSecret: getEnv("JWT_SECRET", ""), + JWTExpiration: 24 * time.Hour, + JWTRefreshExpiration: 7 * 24 * time.Hour, } if cfg.JWTSecret == "" { @@ -39,6 +41,14 @@ func Load() (*Config, error) { cfg.JWTExpiration = d } + if expStr := os.Getenv("JWT_REFRESH_EXPIRATION"); expStr != "" { + d, err := time.ParseDuration(expStr) + if err != nil { + return nil, fmt.Errorf("invalid JWT_REFRESH_EXPIRATION: %w", err) + } + cfg.JWTRefreshExpiration = d + } + return cfg, nil }