261 lines
7.2 KiB
Go
261 lines
7.2 KiB
Go
package common
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
do "github.com/qianfree/team-api/internal/model/do"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/database/gdb"
|
|
"github.com/gogf/gf/v2/errors/gerror"
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/os/gtime"
|
|
|
|
"github.com/qianfree/team-api/internal/dao"
|
|
"github.com/qianfree/team-api/internal/model/entity"
|
|
)
|
|
|
|
// SessionInfo represents a user session for API responses.
|
|
type SessionInfo struct {
|
|
ID int64 `json:"id"`
|
|
UserType string `json:"user_type"`
|
|
UserID int64 `json:"user_id"`
|
|
TenantID int64 `json:"tenant_id,omitempty"`
|
|
DeviceInfo string `json:"device_info,omitempty"`
|
|
IpAddress string `json:"ip_address"`
|
|
ExpiresAt *gtime.Time `json:"expires_at"`
|
|
CreatedAt *gtime.Time `json:"created_at"`
|
|
}
|
|
|
|
// CreateSession creates a new session in the database and enforces max session limit.
|
|
func CreateSession(ctx context.Context, userType string, userID, tenantID int64, refreshTokenHash, ipAddress, deviceInfo string) (sessionID int64, err error) {
|
|
maxSessions := GetMaxSessions(ctx, userType)
|
|
refreshExpire := getRefreshExpire(ctx)
|
|
expiresAt := gtime.New(time.Now().Add(refreshExpire))
|
|
|
|
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
|
// Count existing active sessions
|
|
count, err := tx.Model("sys_sessions").Ctx(ctx).
|
|
Where("user_type", userType).
|
|
Where("user_id", userID).
|
|
Where("expires_at > NOW()").
|
|
Count()
|
|
if err != nil {
|
|
return gerror.Wrapf(err, "count sessions")
|
|
}
|
|
|
|
// Enforce max sessions: delete oldest sessions if over limit
|
|
if int(count) >= maxSessions {
|
|
overCount := int(count) - maxSessions + 1
|
|
// PostgreSQL does not support DELETE ... ORDER BY ... LIMIT,
|
|
// so use a subquery to find the oldest session IDs first.
|
|
var oldIDs []int64
|
|
err = tx.Model("sys_sessions").Ctx(ctx).
|
|
Fields("id").
|
|
Where("user_type", userType).
|
|
Where("user_id", userID).
|
|
Where("expires_at > NOW()").
|
|
OrderAsc("created_at").
|
|
Limit(overCount).
|
|
Scan(&oldIDs)
|
|
if err != nil {
|
|
return gerror.Wrapf(err, "find old sessions")
|
|
}
|
|
if len(oldIDs) > 0 {
|
|
_, err = tx.Model("sys_sessions").Ctx(ctx).
|
|
WhereIn("id", oldIDs).
|
|
Delete()
|
|
if err != nil {
|
|
return gerror.Wrapf(err, "evict old sessions")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Insert new session
|
|
result, err := tx.Model("sys_sessions").Ctx(ctx).Data(do.SysSessions{
|
|
UserType: userType,
|
|
UserId: userID,
|
|
TenantId: tenantID,
|
|
RefreshTokenHash: refreshTokenHash,
|
|
IpAddress: ipAddress,
|
|
DeviceInfo: deviceInfo,
|
|
ExpiresAt: expiresAt,
|
|
}).Insert()
|
|
if err != nil {
|
|
return gerror.Wrapf(err, "insert session")
|
|
}
|
|
|
|
id, err := result.LastInsertId()
|
|
if err != nil {
|
|
return gerror.Wrapf(err, "get session id")
|
|
}
|
|
sessionID = id
|
|
return nil
|
|
})
|
|
|
|
return sessionID, err
|
|
}
|
|
|
|
// RefreshSession rotates a refresh token: invalidates the old one and creates a new session.
|
|
func RefreshSession(ctx context.Context, sessionID int64, oldRefreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo string) error {
|
|
refreshExpire := getRefreshExpire(ctx)
|
|
expiresAt := gtime.New(time.Now().Add(refreshExpire))
|
|
|
|
result, err := dao.SysSessions.Ctx(ctx).
|
|
Where("id", sessionID).
|
|
Where("refresh_token_hash", oldRefreshTokenHash).
|
|
Where("expires_at > NOW()").
|
|
Data(do.SysSessions{
|
|
RefreshTokenHash: newRefreshTokenHash,
|
|
IpAddress: ipAddress,
|
|
DeviceInfo: deviceInfo,
|
|
ExpiresAt: expiresAt,
|
|
}).Update()
|
|
if err != nil {
|
|
return gerror.Wrapf(err, "update session")
|
|
}
|
|
|
|
rowsAffected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return gerror.Wrapf(err, "check rows affected")
|
|
}
|
|
if rowsAffected == 0 {
|
|
return NewUnauthorizedError("会话不存在或已过期")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RevokeSession revokes a single session by ID.
|
|
func RevokeSession(ctx context.Context, sessionID int64) error {
|
|
_, err := dao.SysSessions.Ctx(ctx).
|
|
Where("id", sessionID).
|
|
Delete()
|
|
return err
|
|
}
|
|
|
|
// RevokeAllSessions revokes all active sessions for a user.
|
|
func RevokeAllSessions(ctx context.Context, userType string, userID int64) error {
|
|
_, err := dao.SysSessions.Ctx(ctx).
|
|
Where("user_type", userType).
|
|
Where("user_id", userID).
|
|
Where("expires_at > NOW()").
|
|
Delete()
|
|
return err
|
|
}
|
|
|
|
// ListSessions returns all active sessions for a user.
|
|
func ListSessions(ctx context.Context, userType string, userID int64) ([]SessionInfo, error) {
|
|
var sessions []entity.SysSessions
|
|
err := dao.SysSessions.Ctx(ctx).
|
|
Where("user_type", userType).
|
|
Where("user_id", userID).
|
|
Where("expires_at > NOW()").
|
|
OrderDesc("created_at").
|
|
Scan(&sessions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]SessionInfo, len(sessions))
|
|
for i, s := range sessions {
|
|
result[i] = SessionInfo{
|
|
ID: s.Id,
|
|
UserType: s.UserType,
|
|
UserID: s.UserId,
|
|
TenantID: s.TenantId,
|
|
DeviceInfo: s.DeviceInfo,
|
|
IpAddress: s.IpAddress,
|
|
ExpiresAt: s.ExpiresAt,
|
|
CreatedAt: s.CreatedAt,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// IsSessionRevoked checks if a session has been revoked (via Redis blacklist).
|
|
func IsSessionRevoked(ctx context.Context, sessionID int64) bool {
|
|
key := fmt.Sprintf("session:revoked:%d", sessionID)
|
|
val, err := g.Redis().Do(ctx, "GET", key)
|
|
if err != nil || val.IsNil() {
|
|
return false
|
|
}
|
|
return val.Bool()
|
|
}
|
|
|
|
// MarkSessionRevoked adds a session to the Redis blacklist for instant revocation.
|
|
func MarkSessionRevoked(ctx context.Context, sessionID int64) {
|
|
key := fmt.Sprintf("session:revoked:%d", sessionID)
|
|
// Set TTL to refresh token expiry (7 days) to auto-cleanup
|
|
_, _ = g.Redis().Do(ctx, "SETEX", key, 7*24*3600, "1")
|
|
}
|
|
|
|
// GetSessionByID retrieves a session by its ID.
|
|
func GetSessionByID(ctx context.Context, sessionID int64) (*entity.SysSessions, error) {
|
|
var session entity.SysSessions
|
|
err := dao.SysSessions.Ctx(ctx).
|
|
Where("id", sessionID).
|
|
Where("expires_at > NOW()").
|
|
Scan(&session)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if session.Id == 0 {
|
|
return nil, nil
|
|
}
|
|
return &session, nil
|
|
}
|
|
|
|
// GetSessionByRefreshHash retrieves an active session by refresh token hash.
|
|
func GetSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (*entity.SysSessions, error) {
|
|
var session entity.SysSessions
|
|
err := dao.SysSessions.Ctx(ctx).
|
|
Where("refresh_token_hash", refreshTokenHash).
|
|
Where("expires_at > NOW()").
|
|
Scan(&session)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if session.Id == 0 {
|
|
return nil, nil
|
|
}
|
|
return &session, nil
|
|
}
|
|
|
|
// CleanExpiredSessions removes expired sessions from the database.
|
|
func CleanExpiredSessions(ctx context.Context) (int64, error) {
|
|
result, err := dao.SysSessions.Ctx(ctx).
|
|
Where("expires_at < NOW()").
|
|
Delete()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
// GetCtxUserID extracts user ID from context.
|
|
// The key "userId" is set by admin_auth or tenant_auth middleware.
|
|
func GetCtxUserID(ctx context.Context) int64 {
|
|
val := ctx.Value("userId")
|
|
if val == nil {
|
|
return 0
|
|
}
|
|
if id, ok := val.(int64); ok {
|
|
return id
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// GetCtxSessionID extracts session ID from context.
|
|
// The key "sessionId" is set by admin_auth or tenant_auth middleware.
|
|
func GetCtxSessionID(ctx context.Context) int64 {
|
|
val := ctx.Value("sessionId")
|
|
if val == nil {
|
|
return 0
|
|
}
|
|
if id, ok := val.(int64); ok {
|
|
return id
|
|
}
|
|
return 0
|
|
}
|