Files
Calcium-Ion 31d70fca39 refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
2026-07-20 16:48:43 +08:00

419 lines
14 KiB
Go

package service
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const RefreshCookieName = "new_api_refresh"
var (
ErrLoginSessionInvalid = errors.New("login session is invalid")
ErrLoginSessionRevoked = errors.New("login session is revoked")
ErrLoginSessionMismatch = errors.New("login session does not match the expected session")
ErrRefreshTokenInvalid = errors.New("refresh token is invalid")
ErrRefreshRace = errors.New("refresh token was already rotated")
)
type LoginSessionView struct {
SID string `json:"sid"`
Current bool `json:"current"`
LoginMethod string `json:"login_method"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
CreatedAt int64 `json:"created_at"`
LastActiveAt int64 `json:"last_active_at"`
ExpiresAt int64 `json:"expires_at"`
}
type AuthBundle struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
AccessExpiresAt int64 `json:"access_expires_at"`
Session LoginSessionView `json:"session"`
RefreshToken string `json:"-"`
}
func CreateLoginSession(userID int, loginMethod, ip, userAgent string) (*AuthBundle, error) {
return createLoginSession(userID, 0, loginMethod, ip, userAgent)
}
func CreateLoginSessionAtAuthVersion(userID int, expectedAuthVersion int64, loginMethod, ip, userAgent string) (*AuthBundle, error) {
if expectedAuthVersion <= 0 {
return nil, ErrLoginSessionInvalid
}
return createLoginSession(userID, expectedAuthVersion, loginMethod, ip, userAgent)
}
func createLoginSession(userID int, expectedAuthVersion int64, loginMethod, ip, userAgent string) (*AuthBundle, error) {
user, err := model.GetUserCache(userID)
if err != nil {
return nil, err
}
if user.Status != common.UserStatusEnabled || user.AuthVersion <= 0 {
return nil, ErrLoginSessionInvalid
}
if expectedAuthVersion > 0 && user.AuthVersion != expectedAuthVersion {
return nil, ErrLoginSessionRevoked
}
now := time.Now().Unix()
activeCount, err := model.CountActiveUserSessions(userID, now)
if err != nil {
return nil, err
}
if activeCount >= int64(common.UserSessionActiveLimit) {
return nil, model.ErrUserSessionLimit
}
issuanceCount, err := model.CountUserSessionsCreatedSince(userID, now-common.UserSessionIssuanceWindowSeconds)
if err != nil {
return nil, err
}
if issuanceCount >= int64(common.UserSessionIssuanceLimit) {
return nil, model.ErrUserSessionIssuanceLimit
}
refreshSecret, err := common.GenerateRandomCharsKey(64)
if err != nil {
return nil, err
}
session := &model.UserSession{
SID: uuid.NewString(),
UserID: userID,
Version: 1,
UserAuthVersion: user.AuthVersion,
Status: model.UserSessionStatusActive,
RefreshHash: hashRefreshSecret(refreshSecret),
LoginMethod: strings.TrimSpace(loginMethod),
IP: truncateAuthMetadata(ip, 64),
UserAgent: truncateAuthMetadata(userAgent, 512),
CreatedAt: now,
LastActiveAt: now,
ExpiresAt: time.Unix(now, 0).Add(LoginSessionTTL).Unix(),
}
if session.LoginMethod == "" {
session.LoginMethod = "unknown"
}
if err := model.CreateUserSession(session); err != nil {
return nil, err
}
bundle, err := issueAuthBundle(session, session.SID+"."+refreshSecret, true)
if err != nil {
_, _ = model.RevokeUserSession(userID, session.SID, "token_issue_failed")
return nil, err
}
return bundle, nil
}
func ValidateLoginSession(identity AuthIdentity) (*model.UserSession, *model.UserBase, error) {
session, err := model.GetUserSessionCached(identity.SessionID)
if err != nil {
if errors.Is(err, model.ErrUserSessionInactive) {
return nil, nil, ErrLoginSessionRevoked
}
return nil, nil, err
}
now := time.Now().Unix()
if session.UserID != identity.UserID || session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now || session.Version != identity.SessionVersion || session.UserAuthVersion != identity.UserAuthVersion {
return nil, nil, ErrLoginSessionRevoked
}
user, err := model.GetUserCache(identity.UserID)
if err != nil {
return nil, nil, err
}
if user.Status != common.UserStatusEnabled || user.AuthVersion != identity.UserAuthVersion {
return nil, nil, ErrLoginSessionRevoked
}
return session, user, nil
}
// ValidateSessionReference validates a server-side flow bound to an existing
// dashboard session without requiring an access token on the callback request.
func ValidateSessionReference(userID int, sid string) (AuthIdentity, error) {
if userID <= 0 || strings.TrimSpace(sid) == "" {
return AuthIdentity{}, ErrLoginSessionInvalid
}
session, err := model.GetUserSessionCached(sid)
if err != nil {
return AuthIdentity{}, err
}
identity := AuthIdentity{
UserID: userID,
SessionID: sid,
UserAuthVersion: session.UserAuthVersion,
SessionVersion: session.Version,
}
if _, _, err := ValidateLoginSession(identity); err != nil {
return AuthIdentity{}, err
}
return identity, nil
}
// AdvanceCurrentSessionSecurity increments the user's global auth version,
// preserves only the current browser session at a new session version and
// returns a replacement access token. Call after a successful 2FA/passkey
// security-setting mutation that did not already advance AuthVersion.
func AdvanceCurrentSessionSecurity(identity AuthIdentity, reason string) (*AuthBundle, error) {
nextUserAuthVersion, err := model.BumpUserAuthVersion(identity.UserID)
if err != nil {
return nil, err
}
return advanceCurrentSessionToVersion(identity, nextUserAuthVersion, reason)
}
// AdvanceCurrentSessionToUserVersion is used when the security mutation and
// AuthVersion increment were committed in the same transaction (for example,
// a password change).
func AdvanceCurrentSessionToUserVersion(identity AuthIdentity, reason string) (*AuthBundle, error) {
user, err := model.GetUserCache(identity.UserID)
if err != nil {
return nil, err
}
if user.Status != common.UserStatusEnabled || user.AuthVersion <= identity.UserAuthVersion {
return nil, ErrLoginSessionRevoked
}
return advanceCurrentSessionToVersion(identity, user.AuthVersion, reason)
}
func advanceCurrentSessionToVersion(identity AuthIdentity, nextUserAuthVersion int64, reason string) (*AuthBundle, error) {
session, err := model.AdvanceUserSessionAuthVersion(
identity.UserID,
identity.SessionID,
identity.SessionVersion,
identity.UserAuthVersion,
nextUserAuthVersion,
)
if err != nil {
return nil, err
}
if _, err := model.RevokeOtherUserSessions(identity.UserID, identity.SessionID, reason); err != nil {
return nil, err
}
return issueAuthBundle(session, "", true)
}
func RefreshLoginSession(rawRefreshToken, expectedSID, ip, userAgent string) (*AuthBundle, *model.User, error) {
sid, secret, ok := splitRefreshToken(rawRefreshToken)
if !ok {
return nil, nil, ErrRefreshTokenInvalid
}
if expectedSID = strings.TrimSpace(expectedSID); expectedSID != "" && expectedSID != sid {
return nil, nil, ErrLoginSessionMismatch
}
session, err := model.GetUserSessionCached(sid)
if err != nil {
if errors.Is(err, model.ErrUserSessionInactive) {
return nil, nil, ErrLoginSessionRevoked
}
return nil, nil, ErrRefreshTokenInvalid
}
if session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() {
return nil, nil, ErrLoginSessionRevoked
}
userCache, err := model.GetUserCache(session.UserID)
if err != nil {
return nil, nil, err
}
currentUser, err := model.GetUserById(session.UserID, false)
if err != nil {
return nil, nil, err
}
if userCache.Status != common.UserStatusEnabled || userCache.AuthVersion != session.UserAuthVersion ||
currentUser.Status != common.UserStatusEnabled || currentUser.AuthVersion != session.UserAuthVersion {
_, _ = model.RevokeUserSession(session.UserID, session.SID, "user_security_changed")
return nil, nil, ErrLoginSessionRevoked
}
nextSecret := deriveNextRefreshSecret(sid, secret)
rotated, err := model.RotateUserSessionRefresh(session.UserID, sid, hashRefreshSecret(secret), hashRefreshSecret(nextSecret), time.Now().Unix(), RefreshReplayWindow)
if err != nil {
if errors.Is(err, model.ErrUserSessionRefreshRace) && rotated != nil &&
hashRefreshSecret(nextSecret) == rotated.RefreshHash {
bundle, issueErr := issueAuthBundle(rotated, sid+"."+nextSecret, true)
if issueErr != nil {
return nil, nil, issueErr
}
return bundle, currentUser, nil
}
if errors.Is(err, model.ErrUserSessionRefreshReuse) {
return nil, nil, ErrLoginSessionRevoked
}
if errors.Is(err, model.ErrUserSessionRefreshInvalid) {
return nil, nil, ErrRefreshTokenInvalid
}
if errors.Is(err, model.ErrUserSessionRefreshRace) {
return nil, nil, ErrRefreshRace
}
return nil, nil, err
}
rotated.IP = truncateAuthMetadata(ip, 64)
rotated.UserAgent = truncateAuthMetadata(userAgent, 512)
bundle, err := issueAuthBundle(rotated, sid+"."+nextSecret, true)
if err != nil {
return nil, nil, err
}
return bundle, currentUser, nil
}
func RevokeByRefreshToken(rawRefreshToken, expectedSID, reason string) error {
sid, secret, ok := splitRefreshToken(rawRefreshToken)
if !ok {
return nil
}
if expectedSID = strings.TrimSpace(expectedSID); expectedSID != "" && expectedSID != sid {
return ErrLoginSessionMismatch
}
_, err := model.RevokeUserSessionByRefreshHash(sid, hashRefreshSecret(secret), reason)
return err
}
func RefreshTokenSID(rawRefreshToken string) (string, bool) {
sid, _, ok := splitRefreshToken(rawRefreshToken)
return sid, ok
}
func ListLoginSessions(userID int, currentSID string) ([]LoginSessionView, error) {
sessions, err := model.ListActiveUserSessions(userID, currentSID, time.Now().Unix())
if err != nil {
return nil, err
}
views := make([]LoginSessionView, 0, len(sessions))
for i := range sessions {
views = append(views, sessionView(&sessions[i], sessions[i].SID == currentSID))
}
return views, nil
}
func WriteRefreshCookie(c *gin.Context, rawToken string) {
expiresAt := time.Now().Add(LoginSessionTTL)
if sid, _, ok := splitRefreshToken(rawToken); ok {
if session, err := model.GetUserSessionCached(sid); err == nil && session.ExpiresAt > time.Now().Unix() {
expiresAt = time.Unix(session.ExpiresAt, 0)
}
}
maxAge := int(time.Until(expiresAt) / time.Second)
if maxAge < 1 {
maxAge = 1
}
http.SetCookie(c.Writer, &http.Cookie{
Name: RefreshCookieName,
Value: rawToken,
Path: "/api/user/auth",
MaxAge: maxAge,
Expires: expiresAt,
HttpOnly: true,
Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
}
func ClearRefreshCookie(c *gin.Context) {
http.SetCookie(c.Writer, &http.Cookie{
Name: RefreshCookieName,
Value: "",
Path: "/api/user/auth",
MaxAge: -1,
Expires: time.Unix(1, 0),
HttpOnly: true,
Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
}
func issueAuthBundle(session *model.UserSession, rawRefreshToken string, current bool) (*AuthBundle, error) {
identity := AuthIdentity{
UserID: session.UserID,
SessionID: session.SID,
UserAuthVersion: session.UserAuthVersion,
SessionVersion: session.Version,
}
accessToken, accessExpiresAt, err := IssueAccessToken(identity)
if err != nil {
return nil, err
}
return &AuthBundle{
AccessToken: accessToken,
TokenType: "Bearer",
AccessExpiresAt: accessExpiresAt,
Session: sessionView(session, current),
RefreshToken: rawRefreshToken,
}, nil
}
func sessionView(session *model.UserSession, current bool) LoginSessionView {
return LoginSessionView{
SID: session.SID,
Current: current,
LoginMethod: session.LoginMethod,
IP: session.IP,
UserAgent: session.UserAgent,
CreatedAt: session.CreatedAt,
LastActiveAt: session.LastActiveAt,
ExpiresAt: session.ExpiresAt,
}
}
func splitRefreshToken(raw string) (string, string, bool) {
sid, secret, ok := strings.Cut(strings.TrimSpace(raw), ".")
if !ok || sid == "" || secret == "" || strings.Contains(secret, ".") {
return "", "", false
}
if _, err := uuid.Parse(sid); err != nil {
return "", "", false
}
return sid, secret, true
}
func hashRefreshSecret(secret string) string {
return common.GenerateHMACWithKey(authSigningKey("refresh"), secret)
}
func deriveNextRefreshSecret(sid, currentSecret string) string {
return common.GenerateHMACWithKey(authSigningKey("refresh-rotate"), sid+"."+currentSecret)
}
func truncateAuthMetadata(value string, max int) string {
value = strings.TrimSpace(value)
if len(value) <= max {
return value
}
return value[:max]
}
func authSessionErrorCode(err error) (int, string) {
switch {
case errors.Is(err, model.ErrUserSessionLimit):
return http.StatusConflict, "AUTH_SESSION_LIMIT"
case errors.Is(err, model.ErrUserSessionIssuanceLimit):
return http.StatusTooManyRequests, "AUTH_SESSION_ISSUANCE_LIMIT"
case errors.Is(err, ErrLoginSessionMismatch):
return http.StatusConflict, "AUTH_SESSION_MISMATCH"
case errors.Is(err, ErrRefreshRace):
return http.StatusConflict, "AUTH_REFRESH_RACE"
case errors.Is(err, ErrAuthTokenExpired):
return http.StatusUnauthorized, "AUTH_TOKEN_EXPIRED"
case errors.Is(err, ErrLoginSessionRevoked):
return http.StatusUnauthorized, "AUTH_SESSION_REVOKED"
case errors.Is(err, ErrRefreshTokenInvalid), errors.Is(err, ErrAuthTokenInvalid):
return http.StatusUnauthorized, "AUTH_UNAUTHORIZED"
default:
return http.StatusInternalServerError, "AUTH_INTERNAL_ERROR"
}
}
func AuthSessionErrorCode(err error) (int, string) {
return authSessionErrorCode(err)
}
func FormatAuthError(err error) string {
if err == nil {
return ""
}
return fmt.Sprintf("authentication failed: %v", err)
}