* 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
419 lines
14 KiB
Go
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)
|
|
}
|