feat: add auth flow and login guard for api/web
This commit is contained in:
@@ -32,8 +32,10 @@ func New(configPath string) (*App, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wordService := service.NewWordService(db)
|
wordService := service.NewWordService(db)
|
||||||
|
authService := service.NewAuthService(db)
|
||||||
wordHandler := handler.NewWordHandler(wordService)
|
wordHandler := handler.NewWordHandler(wordService)
|
||||||
engine := router.New(wordHandler)
|
authHandler := handler.NewAuthHandler(authService)
|
||||||
|
engine := router.New(wordHandler, authHandler)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
engine: engine,
|
engine: engine,
|
||||||
|
|||||||
@@ -13,5 +13,5 @@ func InitDB(cfg *config.Config) (*gorm.DB, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func AutoMigrate(db *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{})
|
||||||
}
|
}
|
||||||
|
|||||||
68
memora-api/internal/handler/auth.go
Normal file
68
memora-api/internal/handler/auth.go
Normal file
@@ -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}})
|
||||||
|
}
|
||||||
30
memora-api/internal/middleware/auth.go
Normal file
30
memora-api/internal/middleware/auth.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
16
memora-api/internal/model/user.go
Normal file
16
memora-api/internal/model/user.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
12
memora-api/internal/request/auth.go
Normal file
12
memora-api/internal/request/auth.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
6
memora-api/internal/response/auth.go
Normal file
6
memora-api/internal/response/auth.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
type AuthResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User interface{} `json:"user"`
|
||||||
|
}
|
||||||
@@ -7,18 +7,26 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"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 := gin.Default()
|
||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
api.POST("/words", wordHandler.AddWord)
|
api.POST("/auth/register", authHandler.Register)
|
||||||
api.GET("/words", wordHandler.GetWords)
|
api.POST("/auth/login", authHandler.Login)
|
||||||
api.GET("/review", wordHandler.GetReviewWords)
|
api.GET("/auth/me", authHandler.Me)
|
||||||
api.POST("/review", wordHandler.SubmitReview)
|
|
||||||
api.GET("/stats", wordHandler.GetStatistics)
|
protected := api.Group("")
|
||||||
api.GET("/audio", wordHandler.GetAudio)
|
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
|
return r
|
||||||
|
|||||||
119
memora-api/internal/service/auth.go
Normal file
119
memora-api/internal/service/auth.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container class="layout">
|
<router-view v-if="$route.meta?.fullPage" />
|
||||||
|
<el-container v-else class="layout">
|
||||||
<el-aside width="220px" class="sidebar">
|
<el-aside width="220px" class="sidebar">
|
||||||
<div class="logo">Memora</div>
|
<div class="logo">Memora</div>
|
||||||
<el-menu
|
<el-menu
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import Review from '../views/Review.vue'
|
|||||||
import Statistics from '../views/Statistics.vue'
|
import Statistics from '../views/Statistics.vue'
|
||||||
import Words from '../views/Words.vue'
|
import Words from '../views/Words.vue'
|
||||||
import Settings from '../views/Settings.vue'
|
import Settings from '../views/Settings.vue'
|
||||||
|
import Login from '../views/Login.vue'
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
|
{ path: '/login', name: 'login', component: Login, meta: { public: true, fullPage: true } },
|
||||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||||
{ path: '/memory', name: 'memory', component: Memory },
|
{ path: '/memory', name: 'memory', component: Memory },
|
||||||
{ path: '/review', name: 'review', component: Review },
|
{ path: '/review', name: 'review', component: Review },
|
||||||
|
|||||||
6
memora-web/src/modules/auth/auth.ts
Normal file
6
memora-web/src/modules/auth/auth.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const TOKEN_KEY = 'memora_token'
|
||||||
|
|
||||||
|
export const getToken = () => localStorage.getItem(TOKEN_KEY)
|
||||||
|
export const setToken = (token: string) => localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
export const clearToken = () => localStorage.removeItem(TOKEN_KEY)
|
||||||
|
export const isAuthed = () => !!getToken()
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { routes } from '../app/routes'
|
import { routes } from '../app/routes'
|
||||||
|
import { isAuthed } from '../modules/auth/auth'
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes
|
routes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const isPublic = !!to.meta?.public
|
||||||
|
if (!isPublic && !isAuthed()) {
|
||||||
|
return '/login'
|
||||||
|
}
|
||||||
|
if (to.path === '/login' && isAuthed()) {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { clearToken, getToken } from '../modules/auth/auth'
|
||||||
|
|
||||||
export const http = axios.create({
|
export const http = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
})
|
})
|
||||||
|
|
||||||
|
http.interceptors.request.use((config) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (token) {
|
||||||
|
config.headers = config.headers || {}
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
(res) => res,
|
(res) => res,
|
||||||
(err) => {
|
(err) => {
|
||||||
// 统一错误抛出
|
if (err?.response?.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
if (location.pathname !== '/login') {
|
||||||
|
location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
return Promise.reject(err)
|
return Promise.reject(err)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
70
memora-web/src/views/Login.vue
Normal file
70
memora-web/src/views/Login.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<el-card class="login-card" shadow="hover">
|
||||||
|
<template #header><strong>登录 Memora</strong></template>
|
||||||
|
<el-form :model="form" @submit.prevent="onSubmit">
|
||||||
|
<el-form-item>
|
||||||
|
<el-input v-model="form.email" placeholder="邮箱" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-input v-model="form.password" type="password" show-password placeholder="密码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" style="width:100%" @click="onSubmit">登录</el-button>
|
||||||
|
</el-form>
|
||||||
|
<div class="tips">没有账号会自动注册一个新账号</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { http } from '../services/http'
|
||||||
|
import { setToken } from '../modules/auth/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const form = reactive({ email: '', password: '' })
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!form.email || !form.password) {
|
||||||
|
ElMessage.warning('请填写邮箱和密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const loginRes = await http.post('/auth/login', form)
|
||||||
|
const token = loginRes.data?.data?.token
|
||||||
|
if (token) {
|
||||||
|
setToken(token)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and try register
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await http.post('/auth/register', { ...form, name: form.email.split('@')[0] || 'memora' })
|
||||||
|
const loginRes = await http.post('/auth/login', form)
|
||||||
|
const token = loginRes.data?.data?.token
|
||||||
|
if (token) {
|
||||||
|
setToken(token)
|
||||||
|
ElMessage.success('注册并登录成功')
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.error || '登录失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f4f5fb; }
|
||||||
|
.login-card { width: 360px; }
|
||||||
|
.tips { margin-top: 12px; font-size: 12px; color: #999; text-align: center; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user