994 lines
30 KiB
Go
994 lines
30 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
|
|
"wucher/internal/domain/auth"
|
|
"wucher/internal/shared/pkg/uuidv7"
|
|
)
|
|
|
|
type authSessionRecord struct {
|
|
SessionID string `json:"session_id"`
|
|
UserID string `json:"user_id"`
|
|
CurrentJTI string `json:"current_jti"`
|
|
RefreshExpAt time.Time `json:"refresh_exp_at"`
|
|
AuthProvider string `json:"auth_provider,omitempty"`
|
|
MicrosoftSID string `json:"microsoft_sid,omitempty"`
|
|
DeviceType string `json:"device_type,omitempty"`
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
DeviceName string `json:"device_name"`
|
|
IPAddress string `json:"ip_address,omitempty"`
|
|
LongLived bool `json:"long_lived"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastActiveAt time.Time `json:"last_active_at"`
|
|
}
|
|
|
|
type UserSession struct {
|
|
ID string `json:"id"`
|
|
DeviceName string `json:"device_name"`
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
IPAddress string `json:"ip_address,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastActiveAt time.Time `json:"last_active_at"`
|
|
ActiveNow bool `json:"active_now"`
|
|
}
|
|
|
|
const (
|
|
refreshReplayTTL = 5 * time.Second
|
|
refreshLockTTL = 15 * time.Second
|
|
refreshReplayWaitTimeout = 5 * time.Second
|
|
refreshReplayPollInterval = 50 * time.Millisecond
|
|
)
|
|
|
|
type refreshReplayPayload struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
RefreshTTLSecond int64 `json:"refresh_ttl_second"`
|
|
SessionID string `json:"session_id"`
|
|
UserID string `json:"user_id"`
|
|
}
|
|
|
|
func (p refreshReplayPayload) toTokenPair() TokenPair {
|
|
return TokenPair{
|
|
AccessToken: p.AccessToken,
|
|
RefreshToken: p.RefreshToken,
|
|
RefreshTTL: time.Duration(p.RefreshTTLSecond) * time.Second,
|
|
SessionID: p.SessionID,
|
|
}
|
|
}
|
|
|
|
func newRefreshReplayPayload(pair TokenPair, userID, sessionID string) refreshReplayPayload {
|
|
refreshTTL := pair.RefreshTTL
|
|
if refreshTTL <= 0 {
|
|
refreshTTL = time.Second
|
|
}
|
|
return refreshReplayPayload{
|
|
AccessToken: pair.AccessToken,
|
|
RefreshToken: pair.RefreshToken,
|
|
RefreshTTLSecond: int64(refreshTTL.Seconds()),
|
|
SessionID: strings.TrimSpace(sessionID),
|
|
UserID: strings.TrimSpace(userID),
|
|
}
|
|
}
|
|
|
|
func (s *AuthService) IssueTokensWithClient(ctx context.Context, user *auth.User, userAgent, ipAddress string) (TokenPair, error) {
|
|
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTLocalRefreshTTL, false, userAgent, ipAddress, "", "", "")
|
|
}
|
|
|
|
func (s *AuthService) IssueTokensAfterTOTPWithClient(ctx context.Context, user *auth.User, userAgent, ipAddress string) (TokenPair, error) {
|
|
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTRefreshTOTPTTL, true, userAgent, ipAddress, "", "", "")
|
|
}
|
|
|
|
func (s *AuthService) IssueTokensWithClientAndDeviceType(
|
|
ctx context.Context,
|
|
user *auth.User,
|
|
userAgent, ipAddress, deviceTypeHint string,
|
|
) (TokenPair, error) {
|
|
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTLocalRefreshTTL, false, userAgent, ipAddress, "", deviceTypeHint, "")
|
|
}
|
|
|
|
func (s *AuthService) IssueTokensAfterTOTPWithClientAndDeviceType(
|
|
ctx context.Context,
|
|
user *auth.User,
|
|
userAgent, ipAddress, deviceTypeHint string,
|
|
) (TokenPair, error) {
|
|
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, s.cfg.JWTRefreshTOTPTTL, true, userAgent, ipAddress, "", deviceTypeHint, "")
|
|
}
|
|
|
|
func (s *AuthService) IssueTokensWithClientForProvider(
|
|
ctx context.Context,
|
|
user *auth.User,
|
|
userAgent, ipAddress, authProvider string,
|
|
) (TokenPair, error) {
|
|
refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider)
|
|
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, "", "")
|
|
}
|
|
|
|
func (s *AuthService) IssueTokensWithClientForProviderAndDeviceType(
|
|
ctx context.Context,
|
|
user *auth.User,
|
|
userAgent, ipAddress, authProvider, deviceTypeHint string,
|
|
) (TokenPair, error) {
|
|
refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider)
|
|
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, deviceTypeHint, "")
|
|
}
|
|
|
|
func (s *AuthService) IssueTokensWithClientForProviderAndMicrosoftSID(
|
|
ctx context.Context,
|
|
user *auth.User,
|
|
userAgent, ipAddress, authProvider, microsoftSID string,
|
|
) (TokenPair, error) {
|
|
refreshTTL := s.providerRefreshTTL(ctx, user.ID, authProvider)
|
|
return s.issueLoginTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, false, userAgent, ipAddress, authProvider, "", microsoftSID)
|
|
}
|
|
|
|
// providerRefreshTTL resolves the refresh-token lifetime for a login session.
|
|
// For Microsoft/SSO sessions it follows the lifetime of the stored Microsoft
|
|
// refresh token so the app session stays valid for exactly as long as Microsoft
|
|
// will keep refreshing it (validated on every /auth/refresh). When Microsoft
|
|
// does not report an expiry it falls back to the configured SSO default. Other
|
|
// providers keep the standard refresh TTL.
|
|
func (s *AuthService) providerRefreshTTL(ctx context.Context, userID []byte, authProvider string) time.Duration {
|
|
if !strings.EqualFold(strings.TrimSpace(authProvider), ssoProviderMicrosoft) {
|
|
return s.cfg.JWTRefreshTTL
|
|
}
|
|
fallback := s.cfg.JWTSSORefreshTTL
|
|
if fallback <= 0 {
|
|
fallback = s.cfg.JWTRefreshTTL
|
|
}
|
|
repo, ok := s.repo.(microsoftOAuthTokenStore)
|
|
if !ok {
|
|
return fallback
|
|
}
|
|
record, err := repo.GetMicrosoftOAuthTokenByUserIDProvider(ctx, userID, ssoProviderMicrosoft)
|
|
if err != nil || record == nil || record.RefreshTokenExpiresAt == nil {
|
|
return fallback
|
|
}
|
|
remaining := time.Until(*record.RefreshTokenExpiresAt)
|
|
if remaining <= time.Minute {
|
|
return fallback
|
|
}
|
|
return remaining
|
|
}
|
|
|
|
func (s *AuthService) issueLoginTokensWithRefreshTTLAndClient(
|
|
ctx context.Context,
|
|
user *auth.User,
|
|
refreshTTL time.Duration,
|
|
longLived bool,
|
|
userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID string,
|
|
) (TokenPair, error) {
|
|
if user == nil || len(user.ID) != 16 {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
if !user.IsActive {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
return s.issueTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, longLived, "", userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID)
|
|
}
|
|
|
|
func (s *AuthService) issueTokensWithRefreshTTLAndClient(
|
|
ctx context.Context,
|
|
user *auth.User,
|
|
refreshTTL time.Duration,
|
|
longLived bool,
|
|
sessionID, userAgent, ipAddress, authProvider, deviceTypeHint, microsoftSID string,
|
|
) (TokenPair, error) {
|
|
if s.cfg.JWTAccessSecret == "" || s.cfg.JWTRefreshSecret == "" {
|
|
return TokenPair{}, errors.New("jwt secrets not configured")
|
|
}
|
|
if refreshTTL <= 0 {
|
|
refreshTTL = s.cfg.JWTRefreshTTL
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
newLogin := strings.TrimSpace(sessionID) == ""
|
|
if newLogin {
|
|
sessionID, _ = uuidv7.BytesToString(uuidv7.MustBytes())
|
|
}
|
|
|
|
existingSessionIDs := []string{}
|
|
if s.tokens != nil && newLogin {
|
|
if ids, err := s.loadUserSessionIDs(ctx, user.ID); err == nil {
|
|
existingSessionIDs = ids
|
|
}
|
|
}
|
|
|
|
sessionVersion, _ := s.sessionVersion(ctx, user.ID)
|
|
accessClaims := jwt.MapClaims{
|
|
"sub": bytesToString(user.ID),
|
|
"iat": now.Unix(),
|
|
"exp": now.Add(s.cfg.JWTAccessTTL).Unix(),
|
|
"type": "access",
|
|
"sv": sessionVersion,
|
|
"sid": sessionID,
|
|
}
|
|
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(s.cfg.JWTAccessSecret))
|
|
if err != nil {
|
|
return TokenPair{}, err
|
|
}
|
|
|
|
jti, err := randomToken(32)
|
|
if err != nil {
|
|
return TokenPair{}, err
|
|
}
|
|
refreshClaims := jwt.MapClaims{
|
|
"sub": bytesToString(user.ID),
|
|
"iat": now.Unix(),
|
|
"exp": now.Add(refreshTTL).Unix(),
|
|
"jti": jti,
|
|
"type": "refresh",
|
|
"sv": sessionVersion,
|
|
"sid": sessionID,
|
|
}
|
|
if longLived {
|
|
refreshClaims["ll"] = true
|
|
}
|
|
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(s.cfg.JWTRefreshSecret))
|
|
if err != nil {
|
|
return TokenPair{}, err
|
|
}
|
|
|
|
if s.tokens != nil {
|
|
_ = s.tokens.Set(ctx, refreshKey(jti), []byte(sessionID), refreshTTL)
|
|
if strings.EqualFold(strings.TrimSpace(authProvider), ssoProviderMicrosoft) && strings.TrimSpace(microsoftSID) != "" {
|
|
_ = s.tokens.Set(ctx, microsoftSessionKey(microsoftSID), []byte(sessionID), refreshTTL)
|
|
}
|
|
|
|
createdAt := now
|
|
record, _ := s.loadSessionRecord(ctx, sessionID)
|
|
if record != nil && record.UserID == bytesToString(user.ID) {
|
|
createdAt = record.CreatedAt
|
|
if strings.TrimSpace(authProvider) == "" {
|
|
authProvider = strings.TrimSpace(record.AuthProvider)
|
|
}
|
|
if strings.TrimSpace(userAgent) == "" {
|
|
userAgent = record.UserAgent
|
|
}
|
|
if strings.TrimSpace(ipAddress) == "" {
|
|
ipAddress = record.IPAddress
|
|
}
|
|
}
|
|
|
|
session := &authSessionRecord{
|
|
SessionID: sessionID,
|
|
UserID: bytesToString(user.ID),
|
|
CurrentJTI: jti,
|
|
RefreshExpAt: now.Add(refreshTTL),
|
|
AuthProvider: strings.TrimSpace(authProvider),
|
|
MicrosoftSID: strings.TrimSpace(microsoftSID),
|
|
DeviceType: classifyDeviceType(strings.TrimSpace(userAgent), deviceTypeHint),
|
|
UserAgent: strings.TrimSpace(userAgent),
|
|
DeviceName: deriveDeviceName(strings.TrimSpace(userAgent)),
|
|
IPAddress: sanitizeIP(strings.TrimSpace(ipAddress), s.ipProtector),
|
|
LongLived: longLived,
|
|
CreatedAt: createdAt,
|
|
LastActiveAt: now,
|
|
}
|
|
if err := s.saveSessionRecord(ctx, session, refreshTTL); err == nil {
|
|
if newLogin {
|
|
_ = s.enforceUserDeviceSlots(ctx, user.ID, existingSessionIDs, session)
|
|
} else {
|
|
_ = s.addUserSessionID(ctx, user.ID, sessionID)
|
|
}
|
|
}
|
|
}
|
|
|
|
return TokenPair{
|
|
AccessToken: accessToken,
|
|
RefreshToken: refreshToken,
|
|
RefreshTTL: refreshTTL,
|
|
SessionID: sessionID,
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthService) RefreshTokensWithClient(ctx context.Context, refreshToken, userAgent, ipAddress string) (TokenPair, error) {
|
|
return s.RefreshTokensWithClientAndDeviceType(ctx, refreshToken, userAgent, ipAddress, "")
|
|
}
|
|
|
|
func (s *AuthService) RefreshTokensWithClientAndDeviceType(
|
|
ctx context.Context,
|
|
refreshToken, userAgent, ipAddress, deviceTypeHint string,
|
|
) (TokenPair, error) {
|
|
if refreshToken == "" {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) {
|
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
return []byte(s.cfg.JWTRefreshSecret), nil
|
|
})
|
|
if err != nil || !parsed.Valid {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
|
|
claims, ok := parsed.Claims.(jwt.MapClaims)
|
|
if !ok || claims["type"] != "refresh" {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
jti, _ := claims["jti"].(string)
|
|
sub, _ := claims["sub"].(string)
|
|
sid, _ := claims["sid"].(string)
|
|
if jti == "" || sub == "" || sid == "" {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
userID, err := uuidv7.ParseString(sub)
|
|
if err != nil {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
if s.tokens == nil {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
if !s.validSessionVersion(ctx, userID, claims["sv"]) {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
if !s.isSessionActive(ctx, userID, sid) {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
if replay, ok, err := s.loadRefreshReplay(ctx, jti, userID, sid); err != nil {
|
|
return TokenPair{}, ErrInvalidToken
|
|
} else if ok {
|
|
return replay, nil
|
|
}
|
|
|
|
acquired := false
|
|
acquired, err = s.tokens.SetIfNotExists(ctx, refreshLockKey(sid), []byte(jti), refreshLockTTL)
|
|
if err != nil {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
if !acquired {
|
|
replay, ok, waitErr := s.waitForRefreshReplay(ctx, jti, userID, sid)
|
|
if waitErr != nil {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
if ok {
|
|
return replay, nil
|
|
}
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
defer func() {
|
|
_ = s.tokens.Delete(ctx, refreshLockKey(sid))
|
|
}()
|
|
|
|
var record *authSessionRecord
|
|
var raw []byte
|
|
raw, err = s.tokens.Get(ctx, refreshKey(jti))
|
|
if err != nil || raw == nil || !subtleConstantTimeCompare(raw, []byte(sid)) {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
record, err = s.loadSessionRecord(ctx, sid)
|
|
if err != nil || record == nil {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
if record.UserID != bytesToString(userID) || record.CurrentJTI != jti {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
|
|
if _, err := s.RefreshMicrosoftAccessToken(ctx, userID); err != nil {
|
|
// Only revoke when Microsoft says the grant is permanently invalid
|
|
// (invalid_grant/interaction_required/consent_required, or the
|
|
// stored refresh token is missing). Transient failures — network
|
|
// errors, 5xx, throttling, request timeouts, or an open circuit
|
|
// breaker — must NOT log the user out: keep the app session alive
|
|
// and retry Microsoft on the next refresh cycle.
|
|
if errors.Is(err, ErrMicrosoftReauthRequired) {
|
|
_ = s.RevokeUserSession(ctx, userID, sid)
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
}
|
|
}
|
|
if s.isSessionIdleExpired(record) {
|
|
// SSO sessions may continue when Microsoft token refresh is still valid.
|
|
if !s.canRecoverSSOSessionOnIdle(ctx, userID, record) {
|
|
_ = s.RevokeUserSession(ctx, userID, sid)
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
}
|
|
|
|
user, err := s.repo.GetUserByID(ctx, userID)
|
|
if err != nil || user == nil || !user.IsActive {
|
|
return TokenPair{}, ErrInvalidToken
|
|
}
|
|
longLived := boolClaim(claims["ll"])
|
|
refreshTTL := s.cfg.JWTRefreshTTL
|
|
if longLived {
|
|
refreshTTL = s.cfg.JWTRefreshTOTPTTL
|
|
} else if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
|
|
// Keep the session sliding with the Microsoft refresh token lifetime.
|
|
refreshTTL = s.providerRefreshTTL(ctx, userID, record.AuthProvider)
|
|
}
|
|
pair, err := s.issueTokensWithRefreshTTLAndClient(ctx, user, refreshTTL, longLived, sid, userAgent, ipAddress, "", deviceTypeHint, "")
|
|
if err != nil {
|
|
return TokenPair{}, err
|
|
}
|
|
if s.tokens != nil {
|
|
replay := newRefreshReplayPayload(pair, bytesToString(userID), sid)
|
|
if payload, marshalErr := json.Marshal(replay); marshalErr == nil {
|
|
_ = s.tokens.Set(ctx, refreshReplayKey(jti), payload, refreshReplayTTL)
|
|
}
|
|
}
|
|
return pair, nil
|
|
}
|
|
|
|
func (s *AuthService) loadRefreshReplay(ctx context.Context, jti string, userID []byte, sessionID string) (TokenPair, bool, error) {
|
|
if s.tokens == nil {
|
|
return TokenPair{}, false, nil
|
|
}
|
|
raw, err := s.tokens.Get(ctx, refreshReplayKey(jti))
|
|
if err != nil || raw == nil {
|
|
return TokenPair{}, false, err
|
|
}
|
|
var payload refreshReplayPayload
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
return TokenPair{}, false, err
|
|
}
|
|
if strings.TrimSpace(payload.SessionID) != strings.TrimSpace(sessionID) {
|
|
return TokenPair{}, false, nil
|
|
}
|
|
if strings.TrimSpace(payload.UserID) != bytesToString(userID) {
|
|
return TokenPair{}, false, nil
|
|
}
|
|
if !s.isSessionActive(ctx, userID, sessionID) {
|
|
return TokenPair{}, false, nil
|
|
}
|
|
return payload.toTokenPair(), true, nil
|
|
}
|
|
|
|
func (s *AuthService) waitForRefreshReplay(ctx context.Context, jti string, userID []byte, sessionID string) (TokenPair, bool, error) {
|
|
deadline := time.Now().UTC().Add(refreshReplayWaitTimeout)
|
|
for {
|
|
replay, ok, err := s.loadRefreshReplay(ctx, jti, userID, sessionID)
|
|
if err != nil {
|
|
return TokenPair{}, false, err
|
|
}
|
|
if ok {
|
|
return replay, true, nil
|
|
}
|
|
if time.Now().UTC().After(deadline) {
|
|
return TokenPair{}, false, nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return TokenPair{}, false, ctx.Err()
|
|
case <-time.After(refreshReplayPollInterval):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *AuthService) revokeRefreshTokenWithSession(ctx context.Context, refreshToken string) error {
|
|
if refreshToken == "" {
|
|
return nil
|
|
}
|
|
parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) {
|
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
return []byte(s.cfg.JWTRefreshSecret), nil
|
|
})
|
|
if err != nil || !parsed.Valid {
|
|
return nil
|
|
}
|
|
claims, ok := parsed.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
jti, _ := claims["jti"].(string)
|
|
sid, _ := claims["sid"].(string)
|
|
sub, _ := claims["sub"].(string)
|
|
if sid != "" && sub != "" {
|
|
if userID, err := uuidv7.ParseString(sub); err == nil {
|
|
_ = s.RevokeUserSession(ctx, userID, sid)
|
|
return nil
|
|
}
|
|
}
|
|
if jti != "" && s.tokens != nil {
|
|
_ = s.tokens.Delete(ctx, refreshKey(jti))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) ParseAccessTokenSessionID(ctx context.Context, accessToken string) (string, error) {
|
|
_, sid, err := s.parseAccessTokenWithSession(ctx, accessToken)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return sid, nil
|
|
}
|
|
|
|
func (s *AuthService) GetSessionAuthContextFromAccessToken(ctx context.Context, accessToken string) (string, string, error) {
|
|
_, sid, err := s.parseAccessTokenWithSession(ctx, accessToken)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if s.tokens == nil {
|
|
return "email", "email", nil
|
|
}
|
|
record, err := s.loadSessionRecord(ctx, sid)
|
|
if err != nil || record == nil {
|
|
return "", "", ErrInvalidToken
|
|
}
|
|
provider := strings.ToLower(strings.TrimSpace(record.AuthProvider))
|
|
if provider == ssoProviderMicrosoft {
|
|
return "sso", ssoProviderMicrosoft, nil
|
|
}
|
|
return "email", "email", nil
|
|
}
|
|
|
|
func (s *AuthService) ListUserSessions(ctx context.Context, userID []byte, currentSessionID string) ([]UserSession, error) {
|
|
if len(userID) != 16 {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
if s.tokens == nil {
|
|
return []UserSession{}, nil
|
|
}
|
|
ids, err := s.loadUserSessionIDs(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]UserSession, 0, len(ids))
|
|
for i := range ids {
|
|
record, err := s.loadSessionRecord(ctx, ids[i])
|
|
if err != nil || record == nil {
|
|
continue
|
|
}
|
|
if record.UserID != bytesToString(userID) {
|
|
continue
|
|
}
|
|
out = append(out, UserSession{
|
|
ID: record.SessionID,
|
|
DeviceName: record.DeviceName,
|
|
UserAgent: record.UserAgent,
|
|
IPAddress: record.IPAddress,
|
|
CreatedAt: record.CreatedAt,
|
|
LastActiveAt: record.LastActiveAt,
|
|
ActiveNow: record.SessionID == currentSessionID,
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].LastActiveAt.After(out[j].LastActiveAt)
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func (s *AuthService) RevokeUserSession(ctx context.Context, userID []byte, sessionID string) error {
|
|
if len(userID) != 16 || strings.TrimSpace(sessionID) == "" {
|
|
return ErrInvalidToken
|
|
}
|
|
if s.tokens == nil {
|
|
return nil
|
|
}
|
|
record, err := s.loadSessionRecord(ctx, sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if record == nil {
|
|
return ErrSessionNotFound
|
|
}
|
|
if record.UserID != bytesToString(userID) {
|
|
return ErrInvalidToken
|
|
}
|
|
if strings.TrimSpace(record.CurrentJTI) != "" {
|
|
_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) && strings.TrimSpace(record.MicrosoftSID) != "" {
|
|
_ = s.tokens.Delete(ctx, microsoftSessionKey(record.MicrosoftSID))
|
|
}
|
|
_ = s.tokens.Delete(ctx, sessionKey(sessionID))
|
|
_ = s.removeUserSessionID(ctx, userID, sessionID)
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) RevokeSessionByID(ctx context.Context, sessionID string) error {
|
|
sessionID = strings.TrimSpace(sessionID)
|
|
if sessionID == "" {
|
|
return ErrInvalidToken
|
|
}
|
|
if s.tokens == nil {
|
|
return nil
|
|
}
|
|
record, err := s.loadSessionRecord(ctx, sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if record == nil {
|
|
return ErrSessionNotFound
|
|
}
|
|
userID, err := uuidv7.ParseString(strings.TrimSpace(record.UserID))
|
|
if err != nil {
|
|
return ErrInvalidToken
|
|
}
|
|
return s.RevokeUserSession(ctx, userID, sessionID)
|
|
}
|
|
|
|
func (s *AuthService) RevokeMicrosoftSessionBySID(ctx context.Context, microsoftSID string) error {
|
|
microsoftSID = strings.TrimSpace(microsoftSID)
|
|
if microsoftSID == "" {
|
|
return ErrInvalidToken
|
|
}
|
|
if s.tokens == nil {
|
|
return nil
|
|
}
|
|
raw, err := s.tokens.Get(ctx, microsoftSessionKey(microsoftSID))
|
|
if err != nil || raw == nil {
|
|
return err
|
|
}
|
|
sessionID := strings.TrimSpace(string(raw))
|
|
if sessionID == "" {
|
|
_ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID))
|
|
return ErrSessionNotFound
|
|
}
|
|
record, err := s.loadSessionRecord(ctx, sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if record == nil {
|
|
_ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID))
|
|
return ErrSessionNotFound
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(record.MicrosoftSID), microsoftSID) {
|
|
_ = s.tokens.Delete(ctx, microsoftSessionKey(microsoftSID))
|
|
return ErrSessionNotFound
|
|
}
|
|
userID, err := uuidv7.ParseString(strings.TrimSpace(record.UserID))
|
|
if err != nil {
|
|
return ErrInvalidToken
|
|
}
|
|
return s.RevokeUserSession(ctx, userID, sessionID)
|
|
}
|
|
|
|
func (s *AuthService) RevokeAllUserSessions(ctx context.Context, userID []byte) error {
|
|
if len(userID) != 16 {
|
|
return ErrInvalidToken
|
|
}
|
|
if s.tokens == nil {
|
|
return nil
|
|
}
|
|
sessionIDs, err := s.loadUserSessionIDs(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range sessionIDs {
|
|
sessionID := strings.TrimSpace(sessionIDs[i])
|
|
if sessionID == "" {
|
|
continue
|
|
}
|
|
record, loadErr := s.loadSessionRecord(ctx, sessionID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if record != nil && record.UserID == bytesToString(userID) && strings.TrimSpace(record.CurrentJTI) != "" {
|
|
_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
|
|
if strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) && strings.TrimSpace(record.MicrosoftSID) != "" {
|
|
_ = s.tokens.Delete(ctx, microsoftSessionKey(record.MicrosoftSID))
|
|
}
|
|
}
|
|
_ = s.tokens.Delete(ctx, sessionKey(sessionID))
|
|
}
|
|
_ = s.replaceUserSessionIDs(ctx, userID, []string{})
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) isSessionActive(ctx context.Context, userID []byte, sessionID string) bool {
|
|
if s.tokens == nil || len(userID) != 16 || strings.TrimSpace(sessionID) == "" {
|
|
return false
|
|
}
|
|
ids, err := s.loadUserSessionIDs(ctx, userID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
active := false
|
|
for i := range ids {
|
|
if ids[i] == sessionID {
|
|
active = true
|
|
break
|
|
}
|
|
}
|
|
if !active {
|
|
return false
|
|
}
|
|
record, err := s.loadSessionRecord(ctx, sessionID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if record == nil {
|
|
return false
|
|
}
|
|
if record.UserID != bytesToString(userID) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *AuthService) isSessionIdleExpired(record *authSessionRecord) bool {
|
|
if record == nil || s.cfg.SessionIdleTTL <= 0 || record.LastActiveAt.IsZero() {
|
|
return false
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
|
|
return false
|
|
}
|
|
return record.LastActiveAt.Add(s.cfg.SessionIdleTTL).Before(time.Now().UTC())
|
|
}
|
|
|
|
func (s *AuthService) canRecoverSSOSessionOnIdle(ctx context.Context, userID []byte, record *authSessionRecord) bool {
|
|
if record == nil || !strings.EqualFold(strings.TrimSpace(record.AuthProvider), ssoProviderMicrosoft) {
|
|
return false
|
|
}
|
|
if _, err := s.RefreshMicrosoftAccessToken(ctx, userID); err != nil {
|
|
// Transient Microsoft failures should not force a re-login on an idle
|
|
// session; only a permanent reauth-required error means it cannot
|
|
// recover.
|
|
return !errors.Is(err, ErrMicrosoftReauthRequired)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *AuthService) loadSessionRecord(ctx context.Context, sessionID string) (*authSessionRecord, error) {
|
|
raw, err := s.tokens.Get(ctx, sessionKey(sessionID))
|
|
if err != nil || raw == nil {
|
|
return nil, err
|
|
}
|
|
var record authSessionRecord
|
|
if err := json.Unmarshal(raw, &record); err != nil {
|
|
return nil, err
|
|
}
|
|
return &record, nil
|
|
}
|
|
|
|
func (s *AuthService) saveSessionRecord(ctx context.Context, session *authSessionRecord, ttl time.Duration) error {
|
|
if session == nil || strings.TrimSpace(session.SessionID) == "" {
|
|
return ErrInvalidToken
|
|
}
|
|
payload, err := json.Marshal(session)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !session.RefreshExpAt.IsZero() {
|
|
remaining := time.Until(session.RefreshExpAt)
|
|
if remaining <= 0 {
|
|
return s.tokens.Delete(ctx, sessionKey(session.SessionID))
|
|
}
|
|
ttl = remaining
|
|
}
|
|
return s.tokens.Set(ctx, sessionKey(session.SessionID), payload, ttl)
|
|
}
|
|
|
|
func (s *AuthService) touchSessionActivity(ctx context.Context, userID []byte, sessionID string) {
|
|
if s.tokens == nil || len(userID) != 16 || strings.TrimSpace(sessionID) == "" {
|
|
return
|
|
}
|
|
record, err := s.loadSessionRecord(ctx, sessionID)
|
|
if err != nil || record == nil || record.UserID != bytesToString(userID) {
|
|
return
|
|
}
|
|
record.LastActiveAt = time.Now().UTC()
|
|
if record.RefreshExpAt.IsZero() {
|
|
_ = s.saveSessionRecord(ctx, record, s.cfg.JWTRefreshTTL)
|
|
return
|
|
}
|
|
_ = s.saveSessionRecord(ctx, record, time.Until(record.RefreshExpAt))
|
|
}
|
|
|
|
func (s *AuthService) replaceUserSessionIDs(ctx context.Context, userID []byte, sessionIDs []string) error {
|
|
unique := make([]string, 0, len(sessionIDs))
|
|
seen := make(map[string]struct{}, len(sessionIDs))
|
|
for i := range sessionIDs {
|
|
sessionID := strings.TrimSpace(sessionIDs[i])
|
|
if sessionID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[sessionID]; ok {
|
|
continue
|
|
}
|
|
seen[sessionID] = struct{}{}
|
|
unique = append(unique, sessionID)
|
|
}
|
|
payload, err := json.Marshal(unique)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0)
|
|
}
|
|
|
|
func (s *AuthService) loadUserSessionIDs(ctx context.Context, userID []byte) ([]string, error) {
|
|
raw, err := s.tokens.Get(ctx, userSessionsKey(userID))
|
|
if err != nil || raw == nil {
|
|
return []string{}, err
|
|
}
|
|
var ids []string
|
|
if err := json.Unmarshal(raw, &ids); err != nil {
|
|
return []string{}, nil
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (s *AuthService) addUserSessionID(ctx context.Context, userID []byte, sessionID string) error {
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return nil
|
|
}
|
|
ids, err := s.loadUserSessionIDs(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range ids {
|
|
if ids[i] == sessionID {
|
|
return nil
|
|
}
|
|
}
|
|
ids = append(ids, sessionID)
|
|
payload, err := json.Marshal(ids)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0)
|
|
}
|
|
|
|
func (s *AuthService) revokeStaleUserSessions(ctx context.Context, userID []byte, sessionIDs []string, keepSessionID string) error {
|
|
expectedUserID := bytesToString(userID)
|
|
seen := make(map[string]struct{}, len(sessionIDs))
|
|
for i := range sessionIDs {
|
|
sessionID := strings.TrimSpace(sessionIDs[i])
|
|
if sessionID == "" || sessionID == keepSessionID {
|
|
continue
|
|
}
|
|
if _, ok := seen[sessionID]; ok {
|
|
continue
|
|
}
|
|
seen[sessionID] = struct{}{}
|
|
|
|
record, err := s.loadSessionRecord(ctx, sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if record != nil && record.UserID == expectedUserID && strings.TrimSpace(record.CurrentJTI) != "" {
|
|
_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
|
|
}
|
|
_ = s.tokens.Delete(ctx, sessionKey(sessionID))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) enforceUserDeviceSlots(ctx context.Context, userID []byte, existingSessionIDs []string, current *authSessionRecord) error {
|
|
if current == nil {
|
|
return nil
|
|
}
|
|
currentType := strings.TrimSpace(current.DeviceType)
|
|
if currentType == "" {
|
|
currentType = "unknown"
|
|
}
|
|
kept := make([]string, 0, len(existingSessionIDs)+1)
|
|
seen := map[string]struct{}{}
|
|
for i := range existingSessionIDs {
|
|
sessionID := strings.TrimSpace(existingSessionIDs[i])
|
|
if sessionID == "" || sessionID == current.SessionID {
|
|
continue
|
|
}
|
|
if _, ok := seen[sessionID]; ok {
|
|
continue
|
|
}
|
|
seen[sessionID] = struct{}{}
|
|
record, err := s.loadSessionRecord(ctx, sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if record == nil || record.UserID != bytesToString(userID) {
|
|
continue
|
|
}
|
|
recordType := strings.TrimSpace(record.DeviceType)
|
|
if recordType == "" {
|
|
recordType = classifyDeviceType(record.UserAgent, "")
|
|
}
|
|
if currentType != "unknown" && recordType == currentType {
|
|
if strings.TrimSpace(record.CurrentJTI) != "" {
|
|
_ = s.tokens.Delete(ctx, refreshKey(record.CurrentJTI))
|
|
}
|
|
_ = s.tokens.Delete(ctx, sessionKey(sessionID))
|
|
continue
|
|
}
|
|
kept = append(kept, sessionID)
|
|
}
|
|
kept = append(kept, current.SessionID)
|
|
return s.replaceUserSessionIDs(ctx, userID, kept)
|
|
}
|
|
|
|
func (s *AuthService) removeUserSessionID(ctx context.Context, userID []byte, sessionID string) error {
|
|
ids, err := s.loadUserSessionIDs(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := make([]string, 0, len(ids))
|
|
for i := range ids {
|
|
if ids[i] == sessionID {
|
|
continue
|
|
}
|
|
out = append(out, ids[i])
|
|
}
|
|
payload, err := json.Marshal(out)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.tokens.Set(ctx, userSessionsKey(userID), payload, 0)
|
|
}
|
|
|
|
func sessionKey(sessionID string) string {
|
|
return "auth:session:" + sessionID
|
|
}
|
|
|
|
func microsoftSessionKey(sessionID string) string {
|
|
return "auth:session:microsoft:sid:" + strings.TrimSpace(sessionID)
|
|
}
|
|
|
|
func userSessionsKey(userID []byte) string {
|
|
return "auth:sessions:user:" + bytesToString(userID)
|
|
}
|
|
|
|
func deriveDeviceName(userAgent string) string {
|
|
ua := strings.ToLower(strings.TrimSpace(userAgent))
|
|
if ua == "" {
|
|
return "Unknown device"
|
|
}
|
|
os := "Unknown OS"
|
|
switch {
|
|
case strings.Contains(ua, "iphone"):
|
|
os = "iPhone"
|
|
case strings.Contains(ua, "ipad"):
|
|
os = "iPad"
|
|
case strings.Contains(ua, "android"):
|
|
os = "Android"
|
|
case strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"):
|
|
os = "macOS"
|
|
case strings.Contains(ua, "windows"):
|
|
os = "Windows"
|
|
case strings.Contains(ua, "linux"):
|
|
os = "Linux"
|
|
}
|
|
browser := "Unknown browser"
|
|
switch {
|
|
case strings.Contains(ua, "edg/"):
|
|
browser = "Edge"
|
|
case strings.Contains(ua, "chrome/"):
|
|
browser = "Chrome"
|
|
case strings.Contains(ua, "firefox/"):
|
|
browser = "Firefox"
|
|
case strings.Contains(ua, "safari/"):
|
|
browser = "Safari"
|
|
}
|
|
return browser + " on " + os
|
|
}
|
|
|
|
func classifyDeviceType(userAgent, hint string) string {
|
|
if h := normalizeDeviceTypeHint(hint); h != "" {
|
|
return h
|
|
}
|
|
ua := strings.ToLower(strings.TrimSpace(userAgent))
|
|
if ua == "" {
|
|
return "unknown"
|
|
}
|
|
switch {
|
|
case strings.Contains(ua, "iphone"), strings.Contains(ua, "ipad"), strings.Contains(ua, "android"), strings.Contains(ua, "mobile"):
|
|
return "mobile"
|
|
case strings.Contains(ua, "windows"), strings.Contains(ua, "mac os x"), strings.Contains(ua, "macintosh"), strings.Contains(ua, "linux"), strings.Contains(ua, "x11"):
|
|
return "desktop"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func normalizeDeviceTypeHint(v string) string {
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case "mobile":
|
|
return "mobile"
|
|
case "desktop":
|
|
return "desktop"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|