mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
Each user can now hold up to 20 named access tokens in the user_access_tokens table instead of a single token on the user row. Tokens are shown once at creation (nap_ prefix) and stored as a SHA-256 digest. They expire after 30 days by default; a token without expiry is allowed with a warning. A token only reaches the routes its scopes allow. Scopes are picked from the same permission matrix as admin permissions and are limited to what the owner can do; every management route declares the scope it needs, and routes without a declaration reject tokens. Token management itself always requires a browser session. Tokens can complete step-up verification, so verified actions such as changing a password or 2FA settings work with a token; such changes sign out all browser sessions. Disabling a user keeps their tokens, deleting the account removes them. The existing dashboard token keeps working for 30 days after the upgrade. It can be revoked but no longer regenerated, and afterwards it is rejected with ACCESS_TOKEN_LEGACY_RETIRED. The security page lists tokens with their access records, and audit details show scope names.
494 lines
17 KiB
Go
494 lines
17 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"
|
|
|
|
// SessionHintCookieName is the script-readable companion to RefreshCookieName.
|
|
// See writeSessionHintCookie for why it exists and what it is not.
|
|
const SessionHintCookieName = "new_api_has_session"
|
|
|
|
// SessionHintCookieValue is the only value the hint ever carries.
|
|
const SessionHintCookieValue = "1"
|
|
|
|
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
|
|
}
|
|
session, refreshSecret, err := newLoginSession(userID, user.AuthVersion, loginMethod, ip, userAgent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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 newLoginSession(userID int, authVersion int64, loginMethod, ip, userAgent string) (*model.UserSession, string, error) {
|
|
refreshSecret, err := common.GenerateRandomCharsKey(64)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
now := time.Now().Unix()
|
|
session := &model.UserSession{
|
|
SID: uuid.NewString(),
|
|
UserID: userID,
|
|
Version: 1,
|
|
UserAuthVersion: 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"
|
|
}
|
|
return session, refreshSecret, 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
|
|
}
|
|
|
|
// ValidateStepUpIdentity checks that the identity requesting or consuming a
|
|
// proof is still live: a browser session or a scoped access token.
|
|
func ValidateStepUpIdentity(identity AuthIdentity) error {
|
|
if _, ok := model.ParseAccessTokenSessionID(identity.SessionID); ok {
|
|
return model.ValidateAccessTokenIdentity(identity)
|
|
}
|
|
_, _, err := ValidateLoginSession(identity)
|
|
return err
|
|
}
|
|
|
|
// 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. When the
|
|
// change was made through an access token it returns a nil bundle.
|
|
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) {
|
|
// An access token has no browser session to keep, so every browser session
|
|
// signs in again at the new auth version. The token itself stays valid.
|
|
if _, ok := model.ParseAccessTokenSessionID(identity.SessionID); ok {
|
|
_, err := model.RevokeAllUserSessions(identity.UserID, reason)
|
|
return nil, err
|
|
}
|
|
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.GetSelfUserById(session.UserID)
|
|
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 := max(int(time.Until(expiresAt)/time.Second), 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,
|
|
})
|
|
writeSessionHintCookie(c, maxAge, expiresAt)
|
|
}
|
|
|
|
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,
|
|
})
|
|
clearSessionHintCookie(c)
|
|
}
|
|
|
|
// writeSessionHintCookie mirrors the Refresh Cookie's lifetime with a
|
|
// script-readable marker. The Refresh Cookie itself is HttpOnly and scoped to
|
|
// /api/user/auth, so a page at / cannot tell whether a login session exists;
|
|
// without this hint the frontend has to POST /api/user/auth/refresh on every
|
|
// cold boot just to learn that an anonymous visitor is anonymous. That request
|
|
// is guaranteed to 401 and still consumes a slot of the IP-keyed
|
|
// CriticalRateLimit budget shared by everyone behind the same address.
|
|
//
|
|
// The value is the constant "1" and carries no credential: it states that a
|
|
// Refresh Cookie was issued, never who for. Authorization still derives solely
|
|
// from the Refresh Cookie and the Access Token, so forging this hint only costs
|
|
// the forger the round trip it was meant to avoid.
|
|
//
|
|
// It must be written and cleared in lockstep with the Refresh Cookie, which is
|
|
// why it lives inside these two helpers rather than at their call sites: both
|
|
// cookies then ride the same response with the same expiry, and no login path
|
|
// can set one without the other.
|
|
func writeSessionHintCookie(c *gin.Context, maxAge int, expiresAt time.Time) {
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
Name: SessionHintCookieName,
|
|
Value: SessionHintCookieValue,
|
|
Path: "/",
|
|
MaxAge: maxAge,
|
|
Expires: expiresAt,
|
|
HttpOnly: false,
|
|
Secure: common.SessionCookieSecure,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
func clearSessionHintCookie(c *gin.Context) {
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
Name: SessionHintCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
Expires: time.Unix(1, 0),
|
|
HttpOnly: false,
|
|
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)
|
|
}
|