From 60ce70f532c0687793f9120c303549970c62fec9 Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Fri, 27 Feb 2026 11:07:25 +0800 Subject: [PATCH] feat: add auth flow and login guard for api/web --- memora-api/internal/app/app.go | 4 +- memora-api/internal/bootstrap/database.go | 2 +- memora-api/internal/handler/auth.go | 68 +++++++++++++ memora-api/internal/middleware/auth.go | 30 ++++++ memora-api/internal/model/user.go | 16 +++ memora-api/internal/request/auth.go | 12 +++ memora-api/internal/response/auth.go | 6 ++ memora-api/internal/router/router.go | 22 ++-- memora-api/internal/service/auth.go | 119 ++++++++++++++++++++++ memora-web/src/App.vue | 3 +- memora-web/src/app/routes.ts | 2 + memora-web/src/modules/auth/auth.ts | 6 ++ memora-web/src/router/index.ts | 11 ++ memora-web/src/services/http.ts | 17 +++- memora-web/src/views/Login.vue | 70 +++++++++++++ 15 files changed, 377 insertions(+), 11 deletions(-) create mode 100644 memora-api/internal/handler/auth.go create mode 100644 memora-api/internal/middleware/auth.go create mode 100644 memora-api/internal/model/user.go create mode 100644 memora-api/internal/request/auth.go create mode 100644 memora-api/internal/response/auth.go create mode 100644 memora-api/internal/service/auth.go create mode 100644 memora-web/src/modules/auth/auth.ts create mode 100644 memora-web/src/views/Login.vue diff --git a/memora-api/internal/app/app.go b/memora-api/internal/app/app.go index b5a89d4..d8a0780 100644 --- a/memora-api/internal/app/app.go +++ b/memora-api/internal/app/app.go @@ -32,8 +32,10 @@ func New(configPath string) (*App, error) { } wordService := service.NewWordService(db) + authService := service.NewAuthService(db) wordHandler := handler.NewWordHandler(wordService) - engine := router.New(wordHandler) + authHandler := handler.NewAuthHandler(authService) + engine := router.New(wordHandler, authHandler) return &App{ engine: engine, diff --git a/memora-api/internal/bootstrap/database.go b/memora-api/internal/bootstrap/database.go index c7e6333..def3eb9 100644 --- a/memora-api/internal/bootstrap/database.go +++ b/memora-api/internal/bootstrap/database.go @@ -13,5 +13,5 @@ func InitDB(cfg *config.Config) (*gorm.DB, error) { } func AutoMigrate(db *gorm.DB) error { - return db.AutoMigrate(&model.Word{}, &model.MemoryRecord{}) + return db.AutoMigrate(&model.User{}, &model.Word{}, &model.MemoryRecord{}) } diff --git a/memora-api/internal/handler/auth.go b/memora-api/internal/handler/auth.go new file mode 100644 index 0000000..1c49113 --- /dev/null +++ b/memora-api/internal/handler/auth.go @@ -0,0 +1,68 @@ +package handler + +import ( + "net/http" + "strings" + + "memora-api/internal/request" + "memora-api/internal/service" + + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + authService *service.AuthService +} + +func NewAuthHandler(authService *service.AuthService) *AuthHandler { + return &AuthHandler{authService: authService} +} + +func (h *AuthHandler) Register(c *gin.Context) { + var req request.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.authService.Register(req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": user.ID, "email": user.Email, "name": user.Name}}) +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req request.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + token, user, err := h.authService.Login(req) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": gin.H{"token": token, "user": gin.H{"id": user.ID, "email": user.Email, "name": user.Name}}}) +} + +func (h *AuthHandler) Me(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + return + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + uid, err := service.ParseToken(token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已失效"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": gin.H{"user_id": uid}}) +} diff --git a/memora-api/internal/middleware/auth.go b/memora-api/internal/middleware/auth.go new file mode 100644 index 0000000..4e8e1b9 --- /dev/null +++ b/memora-api/internal/middleware/auth.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "net/http" + "strings" + + "memora-api/internal/service" + + "github.com/gin-gonic/gin" +) + +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + return + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + uid, err := service.ParseToken(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效"}) + return + } + + c.Set("user_id", uid) + c.Next() + } +} diff --git a/memora-api/internal/model/user.go b/memora-api/internal/model/user.go new file mode 100644 index 0000000..63ecfd4 --- /dev/null +++ b/memora-api/internal/model/user.go @@ -0,0 +1,16 @@ +package model + +import "time" + +type User struct { + ID int64 `json:"id" gorm:"primaryKey"` + Email string `json:"email" gorm:"size:120;uniqueIndex;not null"` + Name string `json:"name" gorm:"size:80;not null"` + PasswordHash string `json:"-" gorm:"size:255;not null"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (User) TableName() string { + return "users" +} diff --git a/memora-api/internal/request/auth.go b/memora-api/internal/request/auth.go new file mode 100644 index 0000000..64f2309 --- /dev/null +++ b/memora-api/internal/request/auth.go @@ -0,0 +1,12 @@ +package request + +type RegisterRequest struct { + Email string `json:"email" binding:"required,email"` + Name string `json:"name" binding:"required,min=2,max=30"` + Password string `json:"password" binding:"required,min=6,max=64"` +} + +type LoginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6,max=64"` +} diff --git a/memora-api/internal/response/auth.go b/memora-api/internal/response/auth.go new file mode 100644 index 0000000..2a0fb25 --- /dev/null +++ b/memora-api/internal/response/auth.go @@ -0,0 +1,6 @@ +package response + +type AuthResponse struct { + Token string `json:"token"` + User interface{} `json:"user"` +} diff --git a/memora-api/internal/router/router.go b/memora-api/internal/router/router.go index 71fa9c5..d6b20b8 100644 --- a/memora-api/internal/router/router.go +++ b/memora-api/internal/router/router.go @@ -7,18 +7,26 @@ import ( "github.com/gin-gonic/gin" ) -func New(wordHandler *handler.WordHandler) *gin.Engine { +func New(wordHandler *handler.WordHandler, authHandler *handler.AuthHandler) *gin.Engine { r := gin.Default() r.Use(middleware.CORS()) api := r.Group("/api") { - api.POST("/words", wordHandler.AddWord) - api.GET("/words", wordHandler.GetWords) - api.GET("/review", wordHandler.GetReviewWords) - api.POST("/review", wordHandler.SubmitReview) - api.GET("/stats", wordHandler.GetStatistics) - api.GET("/audio", wordHandler.GetAudio) + api.POST("/auth/register", authHandler.Register) + api.POST("/auth/login", authHandler.Login) + api.GET("/auth/me", authHandler.Me) + + protected := api.Group("") + protected.Use(middleware.AuthRequired()) + { + protected.POST("/words", wordHandler.AddWord) + protected.GET("/words", wordHandler.GetWords) + protected.GET("/review", wordHandler.GetReviewWords) + protected.POST("/review", wordHandler.SubmitReview) + protected.GET("/stats", wordHandler.GetStatistics) + protected.GET("/audio", wordHandler.GetAudio) + } } return r diff --git a/memora-api/internal/service/auth.go b/memora-api/internal/service/auth.go new file mode 100644 index 0000000..245c9b9 --- /dev/null +++ b/memora-api/internal/service/auth.go @@ -0,0 +1,119 @@ +package service + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "memora-api/internal/model" + "memora-api/internal/request" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type AuthService struct { + db *gorm.DB +} + +func NewAuthService(db *gorm.DB) *AuthService { + return &AuthService{db: db} +} + +func jwtSecret() []byte { + secret := os.Getenv("MEMORA_JWT_SECRET") + if secret == "" { + secret = "memora-dev-secret-change-me" + } + return []byte(secret) +} + +func (s *AuthService) Register(req request.RegisterRequest) (*model.User, error) { + var exists model.User + if err := s.db.Where("email = ?", req.Email).First(&exists).Error; err == nil { + return nil, errors.New("邮箱已注册") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + user := model.User{ + Email: req.Email, + Name: req.Name, + PasswordHash: string(hash), + } + + if err := s.db.Create(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func sign(data string) string { + mac := hmac.New(sha256.New, jwtSecret()) + _, _ = mac.Write([]byte(data)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func makeToken(uid int64) string { + exp := time.Now().Add(7 * 24 * time.Hour).Unix() + payload := fmt.Sprintf("%d:%d", uid, exp) + encPayload := base64.RawURLEncoding.EncodeToString([]byte(payload)) + sig := sign(encPayload) + return encPayload + "." + sig +} + +func (s *AuthService) Login(req request.LoginRequest) (string, *model.User, error) { + var user model.User + if err := s.db.Where("email = ?", req.Email).First(&user).Error; err != nil { + return "", nil, errors.New("账号或密码错误") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + return "", nil, errors.New("账号或密码错误") + } + + return makeToken(user.ID), &user, nil +} + +func ParseToken(tokenString string) (int64, error) { + parts := strings.Split(tokenString, ".") + if len(parts) != 2 { + return 0, errors.New("token无效") + } + encPayload, gotSig := parts[0], parts[1] + if sign(encPayload) != gotSig { + return 0, errors.New("token无效") + } + + payloadBytes, err := base64.RawURLEncoding.DecodeString(encPayload) + if err != nil { + return 0, errors.New("token无效") + } + + payloadParts := strings.Split(string(payloadBytes), ":") + if len(payloadParts) != 2 { + return 0, errors.New("token无效") + } + uid, err := strconv.ParseInt(payloadParts[0], 10, 64) + if err != nil { + return 0, errors.New("token无效") + } + exp, err := strconv.ParseInt(payloadParts[1], 10, 64) + if err != nil { + return 0, errors.New("token无效") + } + if time.Now().Unix() > exp { + return 0, errors.New("token已过期") + } + + return uid, nil +} diff --git a/memora-web/src/App.vue b/memora-web/src/App.vue index 57e9168..a32f81f 100644 --- a/memora-web/src/App.vue +++ b/memora-web/src/App.vue @@ -1,5 +1,6 @@