feat: add multi-source breed encyclopedia and two-hour refresh sessions
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var errRefreshInvalid = errors.New("refresh credential invalid")
|
||||
|
||||
func refreshCredential() (string, error) {
|
||||
data := make([]byte, 32)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data), nil
|
||||
}
|
||||
func refreshHash(value string) string {
|
||||
hash := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
func (a *API) sessionPair(id, role, sessionID, refresh string, expires time.Time) (gin.H, error) {
|
||||
token, err := a.sessionToken(id, role, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"token": token, "refresh_token": refresh, "expires_in": 7200, "expires_at": time.Now().Add(2 * time.Hour).Unix(), "refresh_expires_at": expires.Unix()}, nil
|
||||
}
|
||||
func (a *API) newSession(id, role string) (gin.H, error) {
|
||||
raw, err := refreshCredential()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expires := time.Now().Add(2 * time.Hour)
|
||||
sessionID := uuid.NewString()
|
||||
pair, err := a.sessionPair(id, role, sessionID, raw, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Opportunistic cleanup bounded by indexed expiry, without a background scheduler.
|
||||
if err := a.db.Where("expires_at <= ?", time.Now()).Delete(&model.RefreshSession{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = a.db.Create(&model.RefreshSession{Base: model.Base{ID: sessionID}, ActorID: id, Role: role, TokenHash: refreshHash(raw), ExpiresAt: expires}).Error
|
||||
return pair, err
|
||||
}
|
||||
func readRefresh(c *gin.Context) (string, bool) {
|
||||
var in struct {
|
||||
Token string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
if c.ShouldBindJSON(&in) != nil || len(in.Token) != 43 {
|
||||
fail(c, http.StatusUnauthorized, "登录已过期,请重新登录")
|
||||
return "", false
|
||||
}
|
||||
return in.Token, true
|
||||
}
|
||||
func (a *API) refreshToken(role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw, valid := readRefresh(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
var pair gin.H
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var session model.RefreshSession
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).First(&session).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if session.Revoked || !session.ExpiresAt.After(time.Now()) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if role == "user" {
|
||||
var user model.User
|
||||
err = tx.Select("id", "disabled").First(&user, "id = ?", session.ActorID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && user.Disabled) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var count int64
|
||||
if err := tx.Model(&model.Admin{}).Where("id = ?", session.ActorID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
}
|
||||
next, err := refreshCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextExpiry := time.Now().Add(2 * time.Hour)
|
||||
pair, err = a.sessionPair(session.ActorID, role, session.ID, next, nextExpiry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Rotate inside the lock. The old credential can only succeed once.
|
||||
return tx.Model(&session).Updates(map[string]any{"token_hash": refreshHash(next), "expires_at": nextExpiry}).Error
|
||||
})
|
||||
if errors.Is(err, errRefreshInvalid) {
|
||||
fail(c, 401, "登录已过期,请重新登录")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fail(c, 500, "暂时无法刷新登录,请重试")
|
||||
return
|
||||
}
|
||||
ok(c, pair)
|
||||
}
|
||||
}
|
||||
func (a *API) revokeRefresh(role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw, valid := readRefresh(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
if err := a.db.Model(&model.RefreshSession{}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).Update("revoked", true).Error; err != nil {
|
||||
fail(c, 500, "退出登录失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"logged_out": true})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user