mirror of
https://github.com/nais/wonderwall.git
synced 2026-08-23 21:16:14 +00:00
feat(handler/login): add rate limit to prevent redirect loops
Co-authored-by: sindrerh2 <sindre.rodseth.hansen@nav.no>
This commit is contained in:
co-authored by
sindrerh2
parent
64e9167e05
commit
b6bfb817a4
+50
-47
@@ -16,53 +16,56 @@ openid.client-id -> WONDERWALL_OPENID_CLIENT_ID
|
||||
|
||||
The following flags are available:
|
||||
|
||||
| Flag | Type | Default Value | Description |
|
||||
|:-------------------------------------------|:---------|:----------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `auto-login` | boolean | `false` | Enforce authentication if the user does not have a valid session for all matching upstream paths. Automatically redirects HTTP navigation requests to login, otherwise responds with 401 with the Location header set. |
|
||||
| `auto-login-ignore-paths` | strings | | Comma separated list of absolute paths to ignore when `auto-login` is enabled. Supports basic wildcard matching with glob-style asterisks. Invalid patterns are ignored. |
|
||||
| `bind-address` | string | `127.0.0.1:3000` | Listen address for public connections. |
|
||||
| `cookie.prefix` | string | `io.nais.wonderwall` | Prefix for cookie names. |
|
||||
| `cookie.same-site` | string | `Lax` | SameSite attribute for session cookies. One of [Strict, Lax, None]. |
|
||||
| `cookie.secure` | string | `true` | Set secure flag on session cookies. Can only be disabled when `ingress` only consist of localhost hosts. Generally, disabling this is only necessary when using Safari. |
|
||||
| `encryption-key` | string | | Base64 encoded 256-bit cookie encryption key; must be identical in instances that share session store. |
|
||||
| `ingress` | strings | | Comma separated list of ingresses used to access the main application. |
|
||||
| `log-format` | string | `json` | Log format, either `json` or `text`. |
|
||||
| `log-level` | string | `info` | Logging verbosity level. |
|
||||
| `metrics-bind-address` | string | `127.0.0.1:3001` | Listen address for metrics only. |
|
||||
| `openid.acr-values` | string | | Space separated string that configures the default security level (`acr_values`) parameter for authorization requests. |
|
||||
| `openid.audiences` | strings | | List of additional trusted audiences (other than the client_id) for OpenID Connect id_token validation. |
|
||||
| `openid.client-id` | string | | Client ID for the OpenID client. |
|
||||
| `openid.client-jwk` | string | | JWK containing the private key for the OpenID client in string format. If configured, this takes precedence over `openid.client-secret`. |
|
||||
| `openid.client-secret` | string | | Client secret for the OpenID client. Overridden by `openid.client-jwk`, if configured. |
|
||||
| `openid.id-token-signing-alg` | string | `RS256` | Expected JWA value (as defined in RFC 7518) of public keys for validating id_token signatures. This only applies where the key's `alg` header is not set. |
|
||||
| `openid.post-logout-redirect-uri` | string | | URI for redirecting the user after successful logout at the Identity Provider. |
|
||||
| `openid.provider` | string | `openid` | Provider configuration to load and use, either `openid`, `azure`, `idporten`. |
|
||||
| `openid.resource-indicator` | string | | OAuth2 resource indicator to include in authorization request for acquiring audience-restricted tokens. |
|
||||
| `openid.scopes` | strings | | Comma separated list of additional scopes (other than `openid`) that should be used during the login flow. |
|
||||
| `openid.ui-locales` | string | | Space-separated string that configures the default UI locale (`ui_locales`) parameter for OAuth2 consent screen. |
|
||||
| `openid.well-known-url` | string | | URI to the well-known OpenID Configuration metadata document. |
|
||||
| `redis.address` | string | | Deprecated: prefer using `redis.uri`. Address of the Redis instance (host:port). An empty value will use in-memory session storage. Does not override address set by `redis.uri`. |
|
||||
| `redis.connection-idle-timeout` | int | `0` | Idle timeout for Redis connections, in seconds. If non-zero, the value should be less than the client timeout configured at the Redis server. A value of -1 disables timeout. If zero, the default value from go-redis is used (30 minutes). Overrides options set by `redis.uri`. |
|
||||
| `redis.password` | string | | Password for Redis. Overrides password set by `redis.uri`. |
|
||||
| `redis.tls` | boolean | `true` | Whether or not to use TLS for connecting to Redis. Does not override TLS config set by `redis.uri`. |
|
||||
| `redis.uri` | string | | Redis URI string. An empty value will fall back to `redis-address`. |
|
||||
| `redis.username` | string | | Username for Redis. Overrides username set by `redis.uri`. |
|
||||
| `session.forward-auth` | boolean | `false` | Enable endpoint for forward authentication. |
|
||||
| `session.inactivity` | boolean | `false` | Automatically expire user sessions if they have not refreshed their tokens within a given duration. |
|
||||
| `session.inactivity-timeout` | duration | `30m` | Inactivity timeout for user sessions. |
|
||||
| `session.max-lifetime` | duration | `10h` | Max lifetime for user sessions. |
|
||||
| `shutdown-graceful-period` | duration | `30s` | Graceful shutdown period when receiving a shutdown signal after which the server is forcibly exited. |
|
||||
| `shutdown-wait-before-period` | duration | `0s` | Wait period when receiving a shutdown signal before actually starting a graceful shutdown. Useful for allowing propagation of Endpoint updates in Kubernetes. |
|
||||
| `sso.domain` | string | | The domain that the session cookies should be set for, usually the second-level domain name (e.g. `example.com`). |
|
||||
| `sso.enabled` | boolean | `false` | Enable single sign-on mode; one server acting as the OIDC Relying Party, and N proxies. The proxies delegate most endpoint operations to the server, and only implements a reverse proxy that reads the user's session data from the shared store. |
|
||||
| `sso.mode` | string | `server` | The SSO mode for this instance. Must be one of `server` or `proxy`. |
|
||||
| `sso.server-default-redirect-url` | string | | The URL that the SSO server should redirect to by default if a given redirect query parameter is invalid. |
|
||||
| `sso.server-url` | string | | The URL used by the proxy to point to the SSO server instance. |
|
||||
| `sso.session-cookie-name` | string | | Session cookie name. Must be the same across all SSO Servers and Proxies that should share sessions. |
|
||||
| `upstream-host` | string | `127.0.0.1:8080` | Address of upstream host. |
|
||||
| `upstream-ip` | string | | IP of upstream host. Overrides `upstream-host` if set. |
|
||||
| `upstream-port` | int | | Port of upstream host. Overrides `upstream-host` if set. |
|
||||
| `upstream-include-id-token` | boolean | `false` | Include ID token in upstream requests in 'X-Wonderwall-Id-Token' header. |
|
||||
| Flag | Type | Default Value | Description |
|
||||
|:----------------------------------|:---------|:---------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `auto-login` | boolean | `false` | Enforce authentication if the user does not have a valid session for all matching upstream paths. Automatically redirects HTTP navigation requests to login, otherwise responds with 401 with the Location header set. |
|
||||
| `auto-login-ignore-paths` | strings | | Comma separated list of absolute paths to ignore when `auto-login` is enabled. Supports basic wildcard matching with glob-style asterisks. Invalid patterns are ignored. |
|
||||
| `bind-address` | string | `127.0.0.1:3000` | Listen address for public connections. |
|
||||
| `cookie.prefix` | string | `io.nais.wonderwall` | Prefix for cookie names. |
|
||||
| `cookie.same-site` | string | `Lax` | SameSite attribute for session cookies. One of [Strict, Lax, None]. |
|
||||
| `cookie.secure` | string | `true` | Set secure flag on session cookies. Can only be disabled when `ingress` only consist of localhost hosts. Generally, disabling this is only necessary when using Safari. |
|
||||
| `encryption-key` | string | | Base64 encoded 256-bit cookie encryption key; must be identical in instances that share session store. |
|
||||
| `ingress` | strings | | Comma separated list of ingresses used to access the main application. |
|
||||
| `log-format` | string | `json` | Log format, either `json` or `text`. |
|
||||
| `log-level` | string | `info` | Logging verbosity level. |
|
||||
| `metrics-bind-address` | string | `127.0.0.1:3001` | Listen address for metrics only. |
|
||||
| `openid.acr-values` | string | | Space separated string that configures the default security level (`acr_values`) parameter for authorization requests. |
|
||||
| `openid.audiences` | strings | | List of additional trusted audiences (other than the client_id) for OpenID Connect id_token validation. |
|
||||
| `openid.client-id` | string | | Client ID for the OpenID client. |
|
||||
| `openid.client-jwk` | string | | JWK containing the private key for the OpenID client in string format. If configured, this takes precedence over `openid.client-secret`. |
|
||||
| `openid.client-secret` | string | | Client secret for the OpenID client. Overridden by `openid.client-jwk`, if configured. |
|
||||
| `openid.id-token-signing-alg` | string | `RS256` | Expected JWA value (as defined in RFC 7518) of public keys for validating id_token signatures. This only applies where the key's `alg` header is not set. |
|
||||
| `openid.post-logout-redirect-uri` | string | | URI for redirecting the user after successful logout at the Identity Provider. |
|
||||
| `openid.provider` | string | `openid` | Provider configuration to load and use, either `openid`, `azure`, `idporten`. |
|
||||
| `openid.resource-indicator` | string | | OAuth2 resource indicator to include in authorization request for acquiring audience-restricted tokens. |
|
||||
| `openid.scopes` | strings | | Comma separated list of additional scopes (other than `openid`) that should be used during the login flow. |
|
||||
| `openid.ui-locales` | string | | Space-separated string that configures the default UI locale (`ui_locales`) parameter for OAuth2 consent screen. |
|
||||
| `openid.well-known-url` | string | | URI to the well-known OpenID Configuration metadata document. |
|
||||
| `ratelimit.enabled` | boolean | `true` | Enable rate limiting per user-agent. |
|
||||
| `ratelimit.logins` | int | `5` | Maximum permitted login attempts within `ratelimit.window` before rate limiting. |
|
||||
| `ratelimit.window` | duration | `5s` | Time window for counting consecutive attempts towards rate limit. Each attempt within the window will increment the attempt counter and reset the window. If the window expires with no additional attempts, the counter is discarded. |
|
||||
| `redis.address` | string | | Deprecated: prefer using `redis.uri`. Address of the Redis instance (host:port). An empty value will use in-memory session storage. Does not override address set by `redis.uri`. |
|
||||
| `redis.connection-idle-timeout` | int | `0` | Idle timeout for Redis connections, in seconds. If non-zero, the value should be less than the client timeout configured at the Redis server. A value of -1 disables timeout. If zero, the default value from go-redis is used (30 minutes). Overrides options set by `redis.uri`. |
|
||||
| `redis.password` | string | | Password for Redis. Overrides password set by `redis.uri`. |
|
||||
| `redis.tls` | boolean | `true` | Whether or not to use TLS for connecting to Redis. Does not override TLS config set by `redis.uri`. |
|
||||
| `redis.uri` | string | | Redis URI string. An empty value will fall back to `redis-address`. |
|
||||
| `redis.username` | string | | Username for Redis. Overrides username set by `redis.uri`. |
|
||||
| `session.forward-auth` | boolean | `false` | Enable endpoint for forward authentication. |
|
||||
| `session.inactivity` | boolean | `false` | Automatically expire user sessions if they have not refreshed their tokens within a given duration. |
|
||||
| `session.inactivity-timeout` | duration | `30m` | Inactivity timeout for user sessions. |
|
||||
| `session.max-lifetime` | duration | `10h` | Max lifetime for user sessions. |
|
||||
| `shutdown-graceful-period` | duration | `30s` | Graceful shutdown period when receiving a shutdown signal after which the server is forcibly exited. |
|
||||
| `shutdown-wait-before-period` | duration | `0s` | Wait period when receiving a shutdown signal before actually starting a graceful shutdown. Useful for allowing propagation of Endpoint updates in Kubernetes. |
|
||||
| `sso.domain` | string | | The domain that the session cookies should be set for, usually the second-level domain name (e.g. `example.com`). |
|
||||
| `sso.enabled` | boolean | `false` | Enable single sign-on mode; one server acting as the OIDC Relying Party, and N proxies. The proxies delegate most endpoint operations to the server, and only implements a reverse proxy that reads the user's session data from the shared store. |
|
||||
| `sso.mode` | string | `server` | The SSO mode for this instance. Must be one of `server` or `proxy`. |
|
||||
| `sso.server-default-redirect-url` | string | | The URL that the SSO server should redirect to by default if a given redirect query parameter is invalid. |
|
||||
| `sso.server-url` | string | | The URL used by the proxy to point to the SSO server instance. |
|
||||
| `sso.session-cookie-name` | string | | Session cookie name. Must be the same across all SSO Servers and Proxies that should share sessions. |
|
||||
| `upstream-host` | string | `127.0.0.1:8080` | Address of upstream host. |
|
||||
| `upstream-ip` | string | | IP of upstream host. Overrides `upstream-host` if set. |
|
||||
| `upstream-port` | int | | Port of upstream host. Overrides `upstream-host` if set. |
|
||||
| `upstream-include-id-token` | boolean | `false` | Include ID token in upstream requests in `X-Wonderwall-Id-Token` header. |
|
||||
|
||||
Boolean flags are by default set to `false` unless noted otherwise.
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ type Config struct {
|
||||
|
||||
OpenTelemetry OpenTelemetry `json:"otel"`
|
||||
OpenID OpenID `json:"openid"`
|
||||
RateLimit RateLimit `json:"ratelimit"`
|
||||
Redis Redis `json:"redis"`
|
||||
Session Session `json:"session"`
|
||||
SSO SSO `json:"sso"`
|
||||
@@ -85,6 +86,7 @@ func Initialize() (*Config, error) {
|
||||
cookieFlags()
|
||||
openidFlags()
|
||||
otelFlags()
|
||||
rateLimitFlags()
|
||||
redisFlags()
|
||||
sessionFlags()
|
||||
ssoFlags()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
type RateLimit struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Logins int `json:"logins"`
|
||||
Window time.Duration `json:"window"`
|
||||
}
|
||||
|
||||
const (
|
||||
RateLimitEnabled = "ratelimit.enabled"
|
||||
RateLimitLogins = "ratelimit.logins"
|
||||
RateLimitWindow = "ratelimit.window"
|
||||
)
|
||||
|
||||
func rateLimitFlags() {
|
||||
flag.Bool(RateLimitEnabled, true, "Enable rate limiting per user-agent.")
|
||||
flag.Int(RateLimitLogins, 5, "Maximum permitted login attempts within 'ratelimit.window' before rate limiting.")
|
||||
flag.Duration(RateLimitWindow, 5*time.Second, "Time window for counting consecutive attempts towards rate limit."+
|
||||
"Each attempt within the window will increment the attempt counter and reset the window."+
|
||||
"If the window expires with no additional attempts, the counter is discarded.")
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
|
||||
var (
|
||||
Login = login(DefaultPrefix)
|
||||
LoginCount = loginCount(DefaultPrefix)
|
||||
Logout = logout(DefaultPrefix)
|
||||
Retry = retry(DefaultPrefix)
|
||||
Session = session(DefaultPrefix)
|
||||
@@ -161,6 +162,10 @@ func login(prefix string) string {
|
||||
return withPrefix(prefix, "callback")
|
||||
}
|
||||
|
||||
func loginCount(prefix string) string {
|
||||
return withPrefix(prefix, "logincount")
|
||||
}
|
||||
|
||||
func logout(prefix string) string {
|
||||
return withPrefix(prefix, "logout")
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ func (s *Standalone) Unauthorized(w http.ResponseWriter, r *http.Request, cause
|
||||
s.respondError(w, r, http.StatusUnauthorized, cause, log.WarnLevel)
|
||||
}
|
||||
|
||||
func (s *Standalone) TooManyRequests(w http.ResponseWriter, r *http.Request, cause error) {
|
||||
s.respondError(w, r, http.StatusTooManyRequests, cause, log.WarnLevel)
|
||||
}
|
||||
|
||||
// Retry returns a URI that should retry the desired route that failed.
|
||||
// It only handles the routes exposed by Wonderwall, i.e. `/oauth2/*`. As these routes
|
||||
// are related to the authentication flow, we default to redirecting back to the handled
|
||||
@@ -71,7 +75,7 @@ func (s *Standalone) respondError(w http.ResponseWriter, r *http.Request, status
|
||||
incrementRetryAttempt(w, r, s.GetCookieOptions(r))
|
||||
|
||||
attempts, ok := getRetryAttempts(r)
|
||||
if !ok || attempts < MaxAutoRetryAttempts {
|
||||
if (!ok || attempts < MaxAutoRetryAttempts) && (statusCode != http.StatusTooManyRequests) {
|
||||
loginCookie, err := openid.GetLoginCookie(r, s.Crypter)
|
||||
if err != nil {
|
||||
loginCookie = nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
urllib "net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -131,6 +132,12 @@ func (s *Standalone) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = s.applyLoginRateLimit(w, r)
|
||||
if err != nil {
|
||||
s.TooManyRequests(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
opts := s.GetCookieOptions(r).WithSameSite(http.SameSiteLaxMode)
|
||||
err = login.SetCookie(w, opts, s.Crypter, canonicalRedirect)
|
||||
if err != nil {
|
||||
@@ -175,6 +182,55 @@ func (s *Standalone) Login(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, login.AuthCodeURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// applyLoginRateLimit applies a very rudimentary constant rate limit per user-agent, based on cookies.
|
||||
// The rate limit is reset (i.e., the cookie should be expired) after the configured cooldown period.
|
||||
//
|
||||
// This attempts to prevent an endless redirect loop to the authorization endpoint where already authenticated
|
||||
// end-users are being sent to the login endpoint ad infinitum.
|
||||
//
|
||||
// A time window is considered when counting consecutive attempts towards the maximum permitted attempts.
|
||||
// Each attempt within the window will increment the attempt counter and reset the window.
|
||||
// If the window expires with no additional attempts, the counter is discarded.
|
||||
func (s *Standalone) applyLoginRateLimit(w http.ResponseWriter, r *http.Request) error {
|
||||
if !s.Config.RateLimit.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// skip user agents without existing sessions
|
||||
sess, _ := s.SessionManager.Get(r)
|
||||
if sess == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := s.GetCookieOptions(r)
|
||||
c, err := cookie.Get(r, cookie.LoginCount)
|
||||
if err != nil {
|
||||
c = cookie.Make(cookie.LoginCount, "0", opts)
|
||||
}
|
||||
|
||||
attempts, err := strconv.Atoi(c.Value)
|
||||
if err != nil {
|
||||
attempts = 0
|
||||
}
|
||||
|
||||
maxAttempts := s.Config.RateLimit.Logins
|
||||
if attempts >= maxAttempts {
|
||||
return fmt.Errorf("login: rate limiting due to exceeding %d recent attempts", maxAttempts)
|
||||
}
|
||||
|
||||
attempts += 1
|
||||
window := s.Config.RateLimit.Window
|
||||
c = cookie.Make(cookie.LoginCount, strconv.Itoa(attempts), opts)
|
||||
c.MaxAge = int(window.Seconds())
|
||||
|
||||
if attempts >= maxAttempts {
|
||||
mw.LogEntryFrom(r).Warnf("login: reached %d recent attempts; applying rate limit with timeout after %s", maxAttempts, window)
|
||||
}
|
||||
|
||||
cookie.Set(w, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Standalone) LoginCallback(w http.ResponseWriter, r *http.Request) {
|
||||
opts := s.GetCookieOptions(r)
|
||||
logger := mw.LogEntryFrom(r)
|
||||
|
||||
Reference in New Issue
Block a user