140 lines
4.1 KiB
Go
140 lines
4.1 KiB
Go
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})
|
|
}
|
|
}
|