Skip to content

Commit fb33451

Browse files
authored
Merge pull request #187 from fmaass/feature/session-ip-binding
feat(auth): make session IP binding configurable (SESSION_IP_BINDING log|strict|off)
2 parents cac78e2 + f8dc6f0 commit fb33451

10 files changed

Lines changed: 277 additions & 25 deletions

File tree

deploy/.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ DOMAIN=windshift.example.com
1616
# Generate manually with: openssl rand -hex 32
1717
SSO_SECRET=
1818

19+
# How a session presented from a different client IP than it was created from
20+
# is handled (default: log). One of: log, strict, off.
21+
# log - record the mismatch, serve the request, and remember the new IP
22+
# as the session's binding (a parseable IP only; garbage is never
23+
# persisted)
24+
# strict - reject the session, forcing re-authentication
25+
# off - do not compare client IPs at all
26+
# SESSION_IP_BINDING=log
27+
1928
# =============================================================================
2029
# PostgreSQL Settings (uncomment when using PostgreSQL instead of SQLite)
2130
# =============================================================================

deploy/docker-compose-main.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ services:
2323
- PORT=8080
2424
- USE_PROXY=true
2525
- SSO_SECRET=${SSO_SECRET}
26+
- SESSION_IP_BINDING=${SESSION_IP_BINDING:-log}
2627
- ATTACHMENT_PATH=/data/attachments
2728
- DB_TYPE=postgres
2829
- POSTGRES_HOST=db

deploy/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ services:
2525
- PORT=8080
2626
- USE_PROXY=true
2727
- SSO_SECRET=${SSO_SECRET}
28+
- SESSION_IP_BINDING=${SESSION_IP_BINDING:-log}
2829
- ATTACHMENT_PATH=/data/attachments
2930
- DB_PATH=/data/windshift.db
3031
- LOG_LEVEL=info

internal/auth/portal_session.go

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,25 @@ type PortalSession struct {
5757
type PortalSessionManager struct {
5858
cookieManager
5959
db database.Database
60+
// ipBinding is the resolved SESSION_IP_BINDING mode (config.SessionIPBinding*)
61+
// that portal session validation applies to a client-IP change. An unknown
62+
// or zero value is treated as strict so managers built without config.Load
63+
// fail closed.
64+
ipBinding string
6065
}
6166

6267
// NewPortalSessionManager creates a new portal session manager with secure cookie handling.
6368
// If cookieSecret is set, deterministic cookie keys are derived from it
6469
// so that sessions survive process restarts with the same secret.
70+
// ipBinding is the resolved SESSION_IP_BINDING mode; portal sessions share the
71+
// setting with internal user sessions because they enforce the same binding.
6572
// last review: ser, 210426, NOTE: Found hardcoded env var in caller
66-
func NewPortalSessionManager(db database.Database, useSecureCookies, useProxy bool, additionalProxies []string, cookieSecret string) *PortalSessionManager {
73+
func NewPortalSessionManager(db database.Database, useSecureCookies, useProxy bool, additionalProxies []string, cookieSecret, ipBinding string) *PortalSessionManager {
6774
return &PortalSessionManager{
6875
cookieManager: newCookieManager(useSecureCookies, useProxy, additionalProxies, cookieSecret,
6976
"windshift-portal-cookie-hash", "windshift-portal-cookie-block"),
70-
db: db,
77+
db: db,
78+
ipBinding: ipBinding,
7179
}
7280
}
7381

@@ -169,26 +177,45 @@ func (sm *PortalSessionManager) ValidatePortalSession(token, ipAddress string) (
169177
}
170178

171179
// Validate IP address for security. Portal sessions store the client IP at
172-
// creation; subsequent validations must match the same binding used by
173-
// internal user sessions. Legacy rows with no recorded IP are accepted but
174-
// logged so operators can investigate. Missing request IP or mismatch fails
175-
// closed.
176-
switch {
177-
case session.IPAddress == "":
180+
// creation; subsequent validations apply the same SESSION_IP_BINDING mode
181+
// as internal user sessions. Legacy rows with no recorded IP are accepted
182+
// but logged so operators can investigate. Under strict a missing request
183+
// IP or a mismatch fails closed; under log the session is followed to its
184+
// new IP; under off no comparison happens. No mode deactivates the session.
185+
switch decideIPBinding(sm.ipBinding, session.IPAddress, ipAddress) {
186+
case ipBindingLegacyUnbound:
178187
slog.Warn("portal session has no recorded IP, skipping bind check",
179188
slog.Int("portal_customer_id", session.PortalCustomerID),
180189
slog.Int("session_id", session.ID))
181-
case ipAddress == "":
190+
case ipBindingRejectNoRequestIP:
182191
slog.Warn("request has no client IP, rejecting IP-bound portal session",
183192
slog.Int("portal_customer_id", session.PortalCustomerID),
184193
slog.String("session_ip", session.IPAddress))
185194
return nil, ErrPortalSessionInvalid
186-
case session.IPAddress != ipAddress:
195+
case ipBindingAcceptNoRequestIP:
196+
slog.Warn("request has no client IP, accepting IP-bound portal session",
197+
slog.Int("portal_customer_id", session.PortalCustomerID),
198+
slog.String("session_ip", session.IPAddress),
199+
slog.String("session_ip_binding", sm.ipBinding))
200+
case ipBindingAcceptUnparsedIP:
201+
slog.Warn("request client IP is not a valid address, accepting IP-bound portal session without rebinding",
202+
slog.Int("portal_customer_id", session.PortalCustomerID),
203+
slog.String("session_ip", session.IPAddress),
204+
slog.String("request_ip", ipAddress),
205+
slog.String("session_ip_binding", sm.ipBinding))
206+
case ipBindingRejectMismatch:
187207
slog.Warn("portal session IP mismatch",
188208
slog.Int("portal_customer_id", session.PortalCustomerID),
189209
slog.String("session_ip", session.IPAddress),
190210
slog.String("request_ip", ipAddress))
191211
return nil, ErrPortalSessionInvalid
212+
case ipBindingRebindMismatch:
213+
slog.Warn("portal session IP mismatch",
214+
slog.Int("portal_customer_id", session.PortalCustomerID),
215+
slog.String("session_ip", session.IPAddress),
216+
slog.String("request_ip", ipAddress))
217+
sm.rebindPortalSessionIP(token, session, ipAddress)
218+
case ipBindingMatch, ipBindingSkip:
192219
}
193220

194221
if channelID.Valid {
@@ -209,6 +236,27 @@ func (sm *PortalSessionManager) ValidatePortalSession(token, ipAddress string) (
209236
return session, nil
210237
}
211238

239+
// rebindPortalSessionIP moves a portal session to the client IP it is now
240+
// presented from (log mode only). Like the user-session rebind, a failed write
241+
// costs bookkeeping rather than availability: the request is still served and
242+
// the next one retries. Portal sessions have no local validation cache, so
243+
// nothing needs invalidating; the in-memory session is advanced on success
244+
// because it is returned to the caller.
245+
func (sm *PortalSessionManager) rebindPortalSessionIP(token string, session *PortalSession, ipAddress string) {
246+
// Portal tokens are stored as digests with the same legacy plaintext
247+
// fallback as user sessions, so the predicate must match both forms.
248+
query := `UPDATE portal_customer_sessions SET ip_address = ? WHERE session_token IN (?, ?) AND is_active = true`
249+
if _, err := sm.db.ExecWrite(query, ipAddress, hashSessionToken(token), token); err != nil {
250+
slog.Error("failed to rebind portal session to the new client IP",
251+
slog.Int("portal_customer_id", session.PortalCustomerID),
252+
slog.Int("session_id", session.ID),
253+
slog.String("request_ip", ipAddress),
254+
slog.Any("error", err))
255+
return
256+
}
257+
session.IPAddress = ipAddress
258+
}
259+
212260
// DeletePortalSession invalidates a session
213261
// last review: ser, 210426, TODO: Remove inline sql
214262
func (sm *PortalSessionManager) DeletePortalSession(token string) error {

internal/auth/session.go

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ type SessionManager struct {
5454
db database.Database
5555
opaqueKey []byte
5656
sessionValidation *sessionValidator
57+
// ipBinding is the resolved SESSION_IP_BINDING mode (config.SessionIPBinding*)
58+
// that session validation applies to a client-IP change. An unknown or
59+
// zero value is treated as strict so managers built without config.Load
60+
// fail closed.
61+
ipBinding string
5762
}
5863

5964
// Session represents an active user session
@@ -74,28 +79,31 @@ type Session struct {
7479
// NewSessionManager creates a new session manager with secure cookie handling.
7580
// If cookieSecret is non-empty, deterministic cookie keys are derived from it
7681
// so that sessions survive process restarts with the same secret.
82+
// ipBinding is the resolved SESSION_IP_BINDING mode.
7783
// last review: ser, 210426
78-
func NewSessionManager(db database.Database, useSecureCookies, useProxy bool, additionalProxies []string, cookieSecret string) *SessionManager {
84+
func NewSessionManager(db database.Database, useSecureCookies, useProxy bool, additionalProxies []string, cookieSecret, ipBinding string) *SessionManager {
7985
return NewSessionManagerWithValidationCacheTTL(
8086
db,
8187
useSecureCookies,
8288
useProxy,
8389
additionalProxies,
8490
cookieSecret,
91+
ipBinding,
8592
DefaultSessionValidationCacheTTL,
8693
)
8794
}
8895

8996
// NewSessionManagerWithValidationCacheTTL creates a session manager with a
9097
// bounded local validation cache. A non-positive TTL disables retained cache
9198
// entries while preserving in-flight request coalescing.
92-
func NewSessionManagerWithValidationCacheTTL(db database.Database, useSecureCookies, useProxy bool, additionalProxies []string, cookieSecret string, validationCacheTTL time.Duration, cacheSizeMB ...int) *SessionManager {
99+
func NewSessionManagerWithValidationCacheTTL(db database.Database, useSecureCookies, useProxy bool, additionalProxies []string, cookieSecret, ipBinding string, validationCacheTTL time.Duration, cacheSizeMB ...int) *SessionManager {
93100
return newSessionManagerWithValidationCache(
94101
db,
95102
useSecureCookies,
96103
useProxy,
97104
additionalProxies,
98105
cookieSecret,
106+
ipBinding,
99107
validationCacheTTL,
100108
"session_validation",
101109
cacheSizeMB...,
@@ -105,20 +113,21 @@ func NewSessionManagerWithValidationCacheTTL(db database.Database, useSecureCook
105113
// NewSessionManagerWithNamedValidationCacheTTL creates a session manager whose
106114
// validation cache has an explicit diagnostics name. The SSH server uses it so
107115
// the HTTP and SSH allocations remain independently visible.
108-
func NewSessionManagerWithNamedValidationCacheTTL(db database.Database, useSecureCookies, useProxy bool, additionalProxies []string, cookieSecret string, validationCacheTTL time.Duration, cacheName string, cacheSizeMB int) *SessionManager {
116+
func NewSessionManagerWithNamedValidationCacheTTL(db database.Database, useSecureCookies, useProxy bool, additionalProxies []string, cookieSecret, ipBinding string, validationCacheTTL time.Duration, cacheName string, cacheSizeMB int) *SessionManager {
109117
return newSessionManagerWithValidationCache(
110118
db,
111119
useSecureCookies,
112120
useProxy,
113121
additionalProxies,
114122
cookieSecret,
123+
ipBinding,
115124
validationCacheTTL,
116125
cacheName,
117126
cacheSizeMB,
118127
)
119128
}
120129

121-
func newSessionManagerWithValidationCache(db database.Database, useSecureCookies, useProxy bool, additionalProxies []string, cookieSecret string, validationCacheTTL time.Duration, cacheName string, cacheSizeMB ...int) *SessionManager {
130+
func newSessionManagerWithValidationCache(db database.Database, useSecureCookies, useProxy bool, additionalProxies []string, cookieSecret, ipBinding string, validationCacheTTL time.Duration, cacheName string, cacheSizeMB ...int) *SessionManager {
122131
var opaqueKey []byte
123132
if cookieSecret != "" {
124133
opaqueKey = deriveKey(cookieSecret, "windshift-auth-opaque-values", 32)
@@ -131,6 +140,7 @@ func newSessionManagerWithValidationCache(db database.Database, useSecureCookies
131140
db: db,
132141
opaqueKey: opaqueKey,
133142
sessionValidation: newSessionValidator(validationCacheTTL, cacheName, cacheSizeMB...),
143+
ipBinding: ipBinding,
134144
}
135145
}
136146

@@ -255,6 +265,23 @@ func (sm *SessionManager) RefreshSession(token string, rememberMe bool) error {
255265
return nil
256266
}
257267

268+
// UpdateSessionIP rebinds a session to a new client IP. It is used by the log
269+
// SESSION_IP_BINDING mode, where a session that moves between networks is
270+
// followed rather than rejected. Sessions are stored as token digests with a
271+
// legacy plaintext fallback, so the predicate matches both forms — a
272+
// plaintext-only predicate would match zero rows for every current session.
273+
func (sm *SessionManager) UpdateSessionIP(token, ipAddress string) error {
274+
query := `UPDATE user_sessions SET ip_address = ? WHERE session_token IN (?, ?) AND is_active = true`
275+
_, err := sm.db.ExecWrite(query, ipAddress, hashSessionToken(token), token)
276+
if err != nil {
277+
return fmt.Errorf("failed to update session IP: %w", err)
278+
}
279+
// The cached snapshot still carries the previous IP; drop it so the next
280+
// request revalidates against the rebound row instead of rebinding again.
281+
sm.invalidateSessionValidationToken(token)
282+
return nil
283+
}
284+
258285
// SetSessionCookie sets a secure session cookie
259286
func (sm *SessionManager) SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, rememberMe bool) error {
260287
maxAge := int(DefaultSessionDuration.Seconds())

0 commit comments

Comments
 (0)