diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 7bb7fbb780..d6ec6d5c90 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -22,14 +22,15 @@ import ( // Config is the config format for the main application. type Config struct { - Issuer string `json:"issuer"` - Storage Storage `json:"storage"` - Web Web `json:"web"` - Telemetry Telemetry `json:"telemetry"` - OAuth2 OAuth2 `json:"oauth2"` - GRPC GRPC `json:"grpc"` - Expiry Expiry `json:"expiry"` - Logger Logger `json:"logger"` + Issuer string `json:"issuer"` + Storage Storage `json:"storage"` + Web Web `json:"web"` + Telemetry Telemetry `json:"telemetry"` + OAuth2 OAuth2 `json:"oauth2"` + GRPC GRPC `json:"grpc"` + Expiry Expiry `json:"expiry"` + InvalidLoginAttempts InvalidLoginAttempts `json:"invalidLoginAttempts` + Logger Logger `json:"logger"` Frontend server.WebConfig `json:"frontend"` @@ -350,3 +351,8 @@ type RefreshToken struct { AbsoluteLifetime string `json:"absoluteLifetime"` ValidIfNotUsedFor string `json:"validIfNotUsedFor"` } +type InvalidLoginAttempts struct { + EnableInvalidLoginAttempts bool `json:"enableInvalidLoginAttempts"` + BlockedDurationInMinutes int32 `json:"blockedDurationInMinutes"` + MaxInvalidLoginAttemptsAllowed int32 `json:"maxInvalidLoginAttemptsAllowed"` +} diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index c8fb95eb16..8090546658 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -299,6 +299,13 @@ func runServe(options serveOptions) error { logger.Infof("config id tokens valid for: %v", idTokens) serverConfig.IDTokensValidFor = idTokens } + + if c.InvalidLoginAttempts.EnableInvalidLoginAttempts { + serverConfig.EnableInvalidLoginAttempts = c.InvalidLoginAttempts.EnableInvalidLoginAttempts + serverConfig.BlockedDurationInMinutes = c.InvalidLoginAttempts.BlockedDurationInMinutes + serverConfig.MaxInvalidLoginAttemptsAllowed = c.InvalidLoginAttempts.MaxInvalidLoginAttemptsAllowed + } + if c.Expiry.AuthRequests != "" { authRequests, err := time.ParseDuration(c.Expiry.AuthRequests) if err != nil { diff --git a/server/handlers.go b/server/handlers.go index 11dcdd07fd..adabe49b28 100755 --- a/server/handlers.go +++ b/server/handlers.go @@ -1,6 +1,7 @@ package server import ( + "context" "crypto/hmac" "crypto/sha256" "crypto/subtle" @@ -11,9 +12,11 @@ import ( "net/http" "net/url" "path" + "regexp" "sort" "strconv" "strings" + "sync" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -30,6 +33,124 @@ const ( codeChallengeMethodS256 = "S256" ) +var ( + refreshTokenExpiryTime = 1 // In Hour +) + +// newHealthChecker returns the healthz handler. The handler runs until the +// provided context is canceled. +func (s *Server) newHealthChecker(ctx context.Context) http.Handler { + h := &healthChecker{s: s} + + // Perform one health check synchronously so the returned handler returns + // valid data immediately. + h.runHealthCheck() + + go func() { + for { + select { + case <-ctx.Done(): + return + case <-time.After(time.Second * 15): + } + h.runHealthCheck() + } + }() + return h +} + +// healthChecker periodically performs health checks on server dependencies. +// Currently, it only checks that the storage layer is available. +type healthChecker struct { + s *Server + + // Result of the last health check: any error and the amount of time it took + // to query the storage. + mu sync.RWMutex + // Guarded by the mutex + err error + passed time.Duration +} + +// runHealthCheck performs a single health check and makes the result available +// for any clients performing and HTTP request against the healthChecker. +func (h *healthChecker) runHealthCheck() { + t := h.s.now() + err := checkStorageHealth(h.s.storage, h.s.now) + passed := h.s.now().Sub(t) + if err != nil { + h.s.logger.Errorf("Storage health check failed: %v", err) + } + + // Make sure to only hold the mutex to access the fields, and not while + // we're querying the storage object. + h.mu.Lock() + h.err = err + h.passed = passed + h.mu.Unlock() +} + +func checkStorageHealth(s storage.Storage, now func() time.Time) error { + a := storage.AuthRequest{ + ID: storage.NewID(), + ClientID: storage.NewID(), + + // Set a short expiry so if the delete fails this will be cleaned up quickly by garbage collection. + Expiry: now().Add(time.Minute), + } + + if err := s.CreateAuthRequest(a); err != nil { + return fmt.Errorf("create auth request: %v", err) + } + if err := s.DeleteAuthRequest(a.ID); err != nil { + return fmt.Errorf("delete auth request: %v", err) + } + return nil +} + +func (h *healthChecker) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + err := h.err + t := h.passed + h.mu.RUnlock() + + if err != nil { + h.s.renderError(r, w, http.StatusInternalServerError, "Health check failed.") + return + } + fmt.Fprintf(w, "Health check passed in %s", t) +} + +func (s *Server) tokenValidHandler(w http.ResponseWriter, r *http.Request) { + refreshCode := r.PostFormValue("refresh_token") + if refreshCode == "" { + s.tokenErrHelper(w, errInvalidRequest, "No refresh token in request.", http.StatusBadRequest) + return + } + token := new(internal.RefreshToken) + if err := internal.Unmarshal(refreshCode, token); err != nil { + // For backward compatibility, assume the refresh_token is a raw refresh token ID + // if it fails to decode. + // + // Because refresh_token values that aren't unmarshable were generated by servers + // that don't have a Token value, we'll still reject any attempts to claim a + // refresh_token twice. + token = &internal.RefreshToken{RefreshId: refreshCode, Token: ""} + } + + refresh, err := s.storage.GetRefresh(token.RefreshId) + if err != nil { + s.tokenErrHelper(w, errServerError, "Refresh token not Found", http.StatusInternalServerError) + return + } + currTime := time.Now() + diff := currTime.Sub(refresh.LastUsed) + if diff.Hours() >= float64(refreshTokenExpiryTime) { + s.tokenErrHelper(w, errAccessDenied, "Refresh Token Expired", http.StatusUnauthorized) + return + } +} + func (s *Server) handlePublicKeys(w http.ResponseWriter, r *http.Request) { // TODO(ericchiang): Cache this. keys, err := s.storage.GetKeys() @@ -125,6 +246,57 @@ func (s *Server) discoveryHandler() (http.HandlerFunc, error) { }), nil } +func (s *Server) isBlockedTimeExpired(u storage.InvalidLoginAttempt) bool { + if diff := time.Since(u.UpdatedAt); diff.Minutes() > float64(s.blockedDurationInMinutes) { + return true + } + return false +} + +func (s *Server) resetFailedAttempt(username string, w http.ResponseWriter, r *http.Request) { + updater := func(u storage.InvalidLoginAttempt) (storage.InvalidLoginAttempt, error) { + u.InvalidLoginAttemptsCount = 1 + u.UpdatedAt = time.Now() + return u, nil + } + + if err := s.storage.UpdateInvalidLoginAttempt(username, updater); err != nil { + s.logger.Errorf("Failed to reset invalid counter: %v", err) + s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("db error: %v", err)) + return + } +} + +func (s *Server) isAllowedFailedAttemptExceeded(u storage.InvalidLoginAttempt) bool { + return u.InvalidLoginAttemptsCount >= s.maxInvalidLoginAttemptsAllowed +} + +func (s *Server) isUserBlocked(u storage.InvalidLoginAttempt) bool { + diff := time.Since(u.UpdatedAt) + if diff.Minutes() <= float64(s.blockedDurationInMinutes) && u.InvalidLoginAttemptsCount >= s.maxInvalidLoginAttemptsAllowed { + return true + } + return false +} + +func (s *Server) isUserNotFetchedFromDb(username_conn_id string, u storage.InvalidLoginAttempt) bool { + return u.UsernameConnID != strings.ToLower(username_conn_id) +} + +func (s *Server) updateInvalidAttemptCount(username_conn_id string, w http.ResponseWriter, r *http.Request) { + updater := func(u storage.InvalidLoginAttempt) (storage.InvalidLoginAttempt, error) { + u.InvalidLoginAttemptsCount = u.InvalidLoginAttemptsCount + 1 + u.UpdatedAt = time.Now() + return u, nil + } + + if err := s.storage.UpdateInvalidLoginAttempt(username_conn_id, updater); err != nil { + s.logger.Errorf("Failed to increment invalid counter: %v", err) + s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("db error: %v", err)) + return + } +} + // handleAuthorization handles the OAuth2 auth endpoint. func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { // Extract the arguments @@ -186,6 +358,59 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) handleInvalidLoginAttempts(w http.ResponseWriter, r *http.Request, username_conn_id string, InvalidLoginAttempt storage.InvalidLoginAttempt, passwordConnector connector.PasswordConnector, backLink string, username string) { + if !s.enableInvalidLoginAttempts { + if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(passwordConnector), true, backLink, InvalidLoginAttempt.InvalidLoginAttemptsCount, s.maxInvalidLoginAttemptsAllowed, s.blockedDurationInMinutes, s.enableInvalidLoginAttempts, InvalidLoginAttempt.UpdatedAt); err != nil { + s.logger.Errorf("Server template error: %v", err) + } + return + } + + if s.isUserNotFetchedFromDb(username_conn_id, InvalidLoginAttempt) { + //create InvalidLoginAttempt + err := s.storage.CreateInvalidLoginAttempt(storage.InvalidLoginAttempt{ + UsernameConnID: username_conn_id, + InvalidLoginAttemptsCount: 1, + UpdatedAt: time.Now(), + }) + + if err != nil { + s.logger.Errorf("Failed to create InvalidLoginAttempt: %v", err) + s.renderError(r, w, http.StatusInternalServerError, "db error.") + return + } + + if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(passwordConnector), true, backLink, 1, s.maxInvalidLoginAttemptsAllowed, s.blockedDurationInMinutes, s.enableInvalidLoginAttempts, InvalidLoginAttempt.UpdatedAt); err != nil { + s.logger.Errorf("Server template error: %v", err) + } + return + } + + if s.isBlockedTimeExpired(InvalidLoginAttempt) { + s.resetFailedAttempt(username_conn_id, w, r) + if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(passwordConnector), true, backLink, 1, s.maxInvalidLoginAttemptsAllowed, s.blockedDurationInMinutes, s.enableInvalidLoginAttempts, InvalidLoginAttempt.UpdatedAt); err != nil { + s.logger.Errorf("Server template error: %v", err) + } + return + } + + if s.isAllowedFailedAttemptExceeded(InvalidLoginAttempt) { + s.logger.Errorf("User is blocked: %v", InvalidLoginAttempt.InvalidLoginAttemptsCount) + if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(passwordConnector), true, backLink, InvalidLoginAttempt.InvalidLoginAttemptsCount, s.maxInvalidLoginAttemptsAllowed, s.blockedDurationInMinutes, s.enableInvalidLoginAttempts, InvalidLoginAttempt.UpdatedAt); err != nil { + s.logger.Errorf("Server template error: %v", err) + } + return + } + + s.updateInvalidAttemptCount(username_conn_id, w, r) + + if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(passwordConnector), true, backLink, InvalidLoginAttempt.InvalidLoginAttemptsCount+1, s.maxInvalidLoginAttemptsAllowed, s.blockedDurationInMinutes, s.enableInvalidLoginAttempts, + InvalidLoginAttempt.UpdatedAt); err != nil { + s.logger.Errorf("Server template error: %v", err) + } + return +} + func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { authReq, err := s.parseAuthorizationRequest(r) if err != nil { @@ -225,6 +450,14 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } + authReqID := r.FormValue("req") + + if !s.validateBase32EncodedId(authReqID) { + s.logger.Errorf("Invalid auth request id") + s.renderError(r, w, http.StatusBadRequest, "Invalid Auth Request ID") + return + } + authReq.ConnectorID = connID // Actually create the auth request @@ -352,26 +585,46 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink); err != nil { + if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink, 0, s.maxInvalidLoginAttemptsAllowed, s.blockedDurationInMinutes, s.enableInvalidLoginAttempts, time.Now()); err != nil { s.logger.Errorf("Server template error: %v", err) } case http.MethodPost: username := r.FormValue("login") password := r.FormValue("password") scopes := parseScopes(authReq.Scopes) + connID := mux.Vars(r)["connector"] + var InvalidLoginAttempt storage.InvalidLoginAttempt + username_conn_id := username + ":" + connID + + if s.enableInvalidLoginAttempts { + //check if user is in invalid_login_attempts table + InvalidLoginAttempt, err = s.storage.GetInvalidLoginAttempt(username_conn_id) + if err != nil { + s.logger.Errorf("Failed to get InvalidLoginAttempt: %v", err) + s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("Failed to get InvalidLoginAttempt: %v", err)) + return + } + + if s.isUserBlocked(InvalidLoginAttempt) { + s.logger.Errorf("User is blocked") + if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink, InvalidLoginAttempt.InvalidLoginAttemptsCount, s.maxInvalidLoginAttemptsAllowed, s.blockedDurationInMinutes, s.enableInvalidLoginAttempts, InvalidLoginAttempt.UpdatedAt); err != nil { + s.logger.Errorf("Server template error: %v", err) + } + return + } + } identity, ok, err := pwConn.Login(r.Context(), scopes, username, password) if err != nil { s.logger.Errorf("Failed to login user: %v", err) - s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("Login error: %v", err)) + s.handleInvalidLoginAttempts(w, r, username_conn_id, InvalidLoginAttempt, pwConn, backLink, username) return } if !ok { - if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink); err != nil { - s.logger.Errorf("Server template error: %v", err) - } + s.handleInvalidLoginAttempts(w, r, username_conn_id, InvalidLoginAttempt, pwConn, backLink, username) return } + redirectURL, err := s.finalizeLogin(identity, authReq, conn.Connector) if err != nil { s.logger.Errorf("Failed to finalize login: %v", err) @@ -379,6 +632,12 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { return } + if err := s.storage.DeleteInvalidLoginAttempt(username_conn_id); err != nil && err != storage.ErrNotFound { + s.logger.Errorf("Failed to delete InvalidLoginAttempt: %v", err) + s.renderError(r, w, http.StatusInternalServerError, "db error.") + return + } + http.Redirect(w, r, redirectURL, http.StatusSeeOther) default: s.renderError(r, w, http.StatusBadRequest, "Unsupported request method.") @@ -403,6 +662,12 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) return } + if !s.validateBase32EncodedId(authID) { + s.logger.Errorf("Invalid auth request id") + s.renderError(r, w, http.StatusBadRequest, "Invalid Auth Request ID") + return + } + authReq, err := s.storage.GetAuthRequest(authID) if err != nil { if err == storage.ErrNotFound { @@ -411,7 +676,7 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) return } s.logger.Errorf("Failed to get auth request: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Database error.") + s.renderError(r, w, http.StatusInternalServerError, "Auth Request ID not found") return } @@ -565,11 +830,13 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { } authReq, err := s.storage.GetAuthRequest(r.FormValue("req")) - if err != nil { - s.logger.Errorf("Failed to get auth request: %v", err) - s.renderError(r, w, http.StatusInternalServerError, "Database error.") + secureID := r.FormValue("req") + if !s.validateBase32EncodedId(secureID) { + s.logger.Errorf("Invalid auth request") + s.renderError(r, w, http.StatusBadRequest, "Invalid requestID passed") return } + if !authReq.LoggedIn { s.logger.Errorf("Auth request does not have an identity for approval") s.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.") @@ -616,6 +883,12 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe return } + if !s.validateBase32EncodedId(authReq.ID) { + s.logger.Errorf("Invalid auth request") + s.renderError(r, w, http.StatusBadRequest, "Invalid requestID passed") + return + } + if err := s.storage.DeleteAuthRequest(authReq.ID); err != nil { if err != storage.ErrNotFound { s.logger.Errorf("Failed to delete authorization request: %v", err) @@ -836,10 +1109,15 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s if code == "" { s.tokenErrHelper(w, errInvalidRequest, `Required param: code.`, http.StatusBadRequest) + } + if !s.validateBase32EncodedId(code) { + s.logger.Errorf("Invalid auth code") + s.renderError(r, w, http.StatusBadRequest, "Invalid auth code passed") return } authCode, err := s.storage.GetAuthCode(code) + if err != nil || s.now().After(authCode.Expiry) || authCode.ClientID != client.ID { if err != storage.ErrNotFound { s.logger.Errorf("failed to get auth code: %v", err) @@ -890,6 +1168,12 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s } func (s *Server) exchangeAuthCode(w http.ResponseWriter, authCode storage.AuthCode, client storage.Client) (*accessTokenResponse, error) { + if !s.validateBase32EncodedId(authCode.ID) { + s.logger.Errorf("Invalid auth code") + s.tokenErrHelper(w, errInvalidRequest, "Invalid auth code passed", http.StatusBadRequest) + return nil, fmt.Errorf("invalid auth code") + } + accessToken, err := s.newAccessToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, authCode.ConnectorID) if err != nil { s.logger.Errorf("failed to create new access token: %v", err) @@ -1034,6 +1318,206 @@ func (s *Server) exchangeAuthCode(w http.ResponseWriter, authCode storage.AuthCo return s.toAccessTokenResponse(idToken, accessToken, refreshToken, expiry), nil } +// handle a refresh token request https://tools.ietf.org/html/rfc6749#section-6 +// func (s *Server) handleRefreshToken(w http.ResponseWriter, r *http.Request, client storage.Client) { +// code := r.PostFormValue("refresh_token") +// scope := r.PostFormValue("scope") +// if code == "" { +// s.tokenErrHelper(w, errInvalidRequest, "No refresh token in request.", http.StatusBadRequest) +// return +// } +// token := new(internal.RefreshToken) +// if err := internal.Unmarshal(code, token); err != nil { +// // For backward compatibility, assume the refresh_token is a raw refresh token ID +// // if it fails to decode. +// // +// // Because refresh_token values that aren't unmarshable were generated by servers +// // that don't have a Token value, we'll still reject any attempts to claim a +// // refresh_token twice. +// token = &internal.RefreshToken{RefreshId: code, Token: ""} +// } + +// refresh, err := s.storage.GetRefresh(token.RefreshId) + +// if err != nil { +// s.logger.Errorf("failed to get refresh token: %v", err) +// if err == storage.ErrNotFound { +// s.tokenErrHelper(w, errInvalidRequest, "Refresh token is invalid or has already been claimed by another client.", http.StatusBadRequest) +// } else { +// s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) +// } +// return +// } +// if refresh.ClientID != client.ID { +// s.logger.Errorf("client %s trying to claim token for client %s", client.ID, refresh.ClientID) +// s.tokenErrHelper(w, errInvalidRequest, "Refresh token is invalid or has already been claimed by another client.", http.StatusBadRequest) +// return +// } +// if refresh.Token != token.Token { +// s.logger.Errorf("refresh token with id %s claimed twice", refresh.ID) +// s.tokenErrHelper(w, errInvalidRequest, "Refresh token is invalid or has already been claimed by another client.", http.StatusBadRequest) +// return +// } + +// // Per the OAuth2 spec, if the client has omitted the scopes, default to the original +// // authorized scopes. +// // +// // https://tools.ietf.org/html/rfc6749#section-6 +// scopes := refresh.Scopes +// if scope != "" { +// requestedScopes := strings.Fields(scope) +// var unauthorizedScopes []string + +// for _, s := range requestedScopes { +// contains := func() bool { +// for _, scope := range refresh.Scopes { +// if s == scope { +// return true +// } +// } +// return false +// }() +// if !contains { +// unauthorizedScopes = append(unauthorizedScopes, s) +// } +// } + +// if len(unauthorizedScopes) > 0 { +// msg := fmt.Sprintf("Requested scopes contain unauthorized scope(s): %q.", unauthorizedScopes) +// s.tokenErrHelper(w, errInvalidRequest, msg, http.StatusBadRequest) +// return +// } +// scopes = requestedScopes +// } + +// var connectorData []byte + +// session, err := s.storage.GetOfflineSessions(refresh.Claims.UserID, refresh.ConnectorID) +// switch { +// case err != nil: +// if err != storage.ErrNotFound { +// s.logger.Errorf("failed to get offline session: %v", err) +// return +// } +// case len(refresh.ConnectorData) > 0: +// // Use the old connector data if it exists, should be deleted once used +// connectorData = refresh.ConnectorData +// default: +// connectorData = session.ConnectorData +// } + +// conn, err := s.getConnector(refresh.ConnectorID) +// if err != nil { +// s.logger.Errorf("connector with ID %q not found: %v", refresh.ConnectorID, err) +// s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) +// return +// } +// ident := connector.Identity{ +// UserID: refresh.Claims.UserID, +// Username: refresh.Claims.Username, +// PreferredUsername: refresh.Claims.PreferredUsername, +// Email: refresh.Claims.Email, +// EmailVerified: refresh.Claims.EmailVerified, +// Groups: refresh.Claims.Groups, +// ConnectorData: connectorData, +// } + +// // Can the connector refresh the identity? If so, attempt to refresh the data +// // in the connector. +// // +// // TODO(ericchiang): We may want a strict mode where connectors that don't implement +// // this interface can't perform refreshing. +// if refreshConn, ok := conn.Connector.(connector.RefreshConnector); ok { +// newIdent, err := refreshConn.Refresh(r.Context(), parseScopes(scopes), ident) +// if err != nil { +// s.logger.Errorf("failed to refresh identity: %v", err) +// s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) +// return +// } +// ident = newIdent +// } + +// claims := storage.Claims{ +// UserID: ident.UserID, +// Username: ident.Username, +// PreferredUsername: ident.PreferredUsername, +// Email: ident.Email, +// EmailVerified: ident.EmailVerified, +// Groups: ident.Groups, +// } + +// accessToken, err := s.newAccessToken(client.ID, claims, scopes, refresh.Nonce, refresh.ConnectorID) +// if err != nil { +// s.logger.Errorf("failed to create new access token: %v", err) +// s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) +// return +// } + +// idToken, expiry, err := s.newIDToken(client.ID, claims, scopes, refresh.Nonce, accessToken, refresh.ConnectorID) +// if err != nil { +// s.logger.Errorf("failed to create ID token: %v", err) +// s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) +// return +// } + +// newToken := &internal.RefreshToken{ +// RefreshId: refresh.ID, +// Token: storage.NewID(), +// } +// rawNewToken, err := internal.Marshal(newToken) +// if err != nil { +// s.logger.Errorf("failed to marshal refresh token: %v", err) +// s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) +// return +// } + +// lastUsed := s.now() +// updater := func(old storage.RefreshToken) (storage.RefreshToken, error) { +// if old.Token != refresh.Token { +// return old, errors.New("refresh token claimed twice") +// } +// old.Token = newToken.Token +// // Update the claims of the refresh token. +// // +// // UserID intentionally ignored for now. +// old.Claims.Username = ident.Username +// old.Claims.PreferredUsername = ident.PreferredUsername +// old.Claims.Email = ident.Email +// old.Claims.EmailVerified = ident.EmailVerified +// old.Claims.Groups = ident.Groups +// old.LastUsed = lastUsed + +// // ConnectorData has been moved to OfflineSession +// old.ConnectorData = []byte{} +// return old, nil +// } + +// // Update LastUsed time stamp in refresh token reference object +// // in offline session for the user. +// if err := s.storage.UpdateOfflineSessions(refresh.Claims.UserID, refresh.ConnectorID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) { +// if old.Refresh[refresh.ClientID].ID != refresh.ID { +// return old, errors.New("refresh token invalid") +// } +// old.Refresh[refresh.ClientID].LastUsed = lastUsed +// old.ConnectorData = ident.ConnectorData +// return old, nil +// }); err != nil { +// s.logger.Errorf("failed to update offline session: %v", err) +// s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) +// return +// } + +// // Update refresh token in the storage. +// if err := s.storage.UpdateRefreshToken(refresh.ID, updater); err != nil { +// s.logger.Errorf("failed to update refresh token: %v", err) +// s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) +// return +// } + +// resp := s.toAccessTokenResponse(idToken, accessToken, rawNewToken, expiry) +// s.writeAccessToken(w, resp) +// } + func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) { const prefix = "Bearer " @@ -1338,6 +1822,14 @@ func (s *Server) tokenErrHelper(w http.ResponseWriter, typ string, description s } } +// Validate Auth Request ID +func (s *Server) validateBase32EncodedId(id string) bool { + // Request ID is a 32 bit encoded string + // Check https://github.com/chef/dex-1/blob/master/storage/storage.go#L41 for details + re := regexp.MustCompile(`(?i)^[a-z0-7]+$`) + return re.Match([]byte(id)) +} + // Check for username prompt override from connector. Defaults to "Username". func usernamePrompt(conn connector.PasswordConnector) string { if attr := conn.Prompt(); attr != "" { diff --git a/server/handlers_test.go b/server/handlers_test.go index fb1a05064f..0abe93148d 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -310,3 +310,29 @@ func TestPasswordConnectorDataNotEmpty(t *testing.T) { require.NoError(t, err) require.Equal(t, `{"test": "true"}`, string(newSess.ConnectorData)) } +func TestHandleInvalidApprovalCode(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + httpServer, server := newTestServer(ctx, t, func(c *Config) { + c.Storage = &emptyStorage{c.Storage} + }) + defer httpServer.Close() + + tests := []struct { + TargetURI string + ExpectedCode int + }{ + {"/approval?req=etsk5jfjfq2ao7s2pfthls72m%20HTTP/1.1", http.StatusBadRequest}, + {"/approval?req=ProbePhishing", http.StatusBadRequest}, + } + + rr := httptest.NewRecorder() + + for i, r := range tests { + server.ServeHTTP(rr, httptest.NewRequest("GET", r.TargetURI, nil)) + if rr.Code != r.ExpectedCode { + t.Fatalf("test %d expected %d, got %d", i, r.ExpectedCode, rr.Code) + } + } +} diff --git a/server/server.go b/server/server.go index df16e655cf..21717d0a9f 100755 --- a/server/server.go +++ b/server/server.go @@ -90,6 +90,9 @@ type Config struct { // Refresh token expiration settings RefreshTokenPolicy *RefreshTokenPolicy + EnableInvalidLoginAttempts bool + BlockedDurationInMinutes int32 + MaxInvalidLoginAttemptsAllowed int32 // If set, the server will use this connector to handle password grants PasswordConnector string @@ -179,7 +182,10 @@ type Server struct { authRequestsValidFor time.Duration deviceRequestsValidFor time.Duration - refreshTokenPolicy *RefreshTokenPolicy + refreshTokenPolicy *RefreshTokenPolicy + enableInvalidLoginAttempts bool + blockedDurationInMinutes int32 + maxInvalidLoginAttemptsAllowed int32 logger log.Logger } @@ -263,21 +269,24 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) } s := &Server{ - issuerURL: *issuerURL, - connectors: make(map[string]Connector), - storage: newKeyCacher(c.Storage, now), - supportedResponseTypes: supportedRes, - supportedGrantTypes: supportedGrant, - idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), - authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour), - deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute), - refreshTokenPolicy: c.RefreshTokenPolicy, - skipApproval: c.SkipApprovalScreen, - alwaysShowLogin: c.AlwaysShowLoginScreen, - now: now, - templates: tmpls, - passwordConnector: c.PasswordConnector, - logger: c.Logger, + issuerURL: *issuerURL, + connectors: make(map[string]Connector), + storage: newKeyCacher(c.Storage, now), + supportedResponseTypes: supportedRes, + supportedGrantTypes: supportedGrant, + idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), + authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour), + deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute), + refreshTokenPolicy: c.RefreshTokenPolicy, + skipApproval: c.SkipApprovalScreen, + alwaysShowLogin: c.AlwaysShowLoginScreen, + now: now, + templates: tmpls, + passwordConnector: c.PasswordConnector, + logger: c.Logger, + enableInvalidLoginAttempts: c.EnableInvalidLoginAttempts, + blockedDurationInMinutes: c.BlockedDurationInMinutes, + maxInvalidLoginAttemptsAllowed: c.MaxInvalidLoginAttemptsAllowed, } // Retrieves connector objects in backend storage. This list includes the static connectors @@ -357,6 +366,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) handleWithCORS("/token", s.handleToken) handleWithCORS("/keys", s.handlePublicKeys) handleWithCORS("/userinfo", s.handleUserInfo) + handleFunc("/refreshtokenvalid", s.tokenValidHandler) handleFunc("/auth", s.handleAuthorization) handleFunc("/auth/{connector}", s.handleConnectorLogin) handleFunc("/auth/{connector}/login", s.handlePasswordLogin) diff --git a/server/templates.go b/server/templates.go index 33f00fda36..eaf62c960c 100644 --- a/server/templates.go +++ b/server/templates.go @@ -11,6 +11,8 @@ import ( "sort" "strings" + "time" + "github.com/Masterminds/sprig/v3" ) @@ -286,15 +288,20 @@ func (t *templates) login(r *http.Request, w http.ResponseWriter, connectors []c return renderTemplate(w, t.loginTmpl, data) } -func (t *templates) password(r *http.Request, w http.ResponseWriter, postURL, lastUsername, usernamePrompt string, lastWasInvalid bool, backLink string) error { +func (t *templates) password(r *http.Request, w http.ResponseWriter, postURL, lastUsername, usernamePrompt string, lastWasInvalid bool, backLink string, InvalidLoginAttemptsCount int32, maxInvalidLoginAttemptsAllowed int32, blockedDurationInMinutes int32, enableInvalidLoginAttempts bool, invalidLoginAttemptUpdatedAt time.Time) error { data := struct { - PostURL string - BackLink string - Username string - UsernamePrompt string - Invalid bool - ReqPath string - }{postURL, backLink, lastUsername, usernamePrompt, lastWasInvalid, r.URL.Path} + PostURL string + BackLink string + Username string + UsernamePrompt string + Invalid bool + ReqPath string + InvalidLoginAttemptsCount int32 + MaxInvalidLoginAttemptsAllowed int32 + BlockedDurationInMinutes int32 + EnableInvalidLoginAttempts bool + InvalidLoginAttemptUpdatedAt time.Time + }{postURL, backLink, lastUsername, usernamePrompt, lastWasInvalid, r.URL.Path, InvalidLoginAttemptsCount, maxInvalidLoginAttemptsAllowed, blockedDurationInMinutes, enableInvalidLoginAttempts, invalidLoginAttemptUpdatedAt} return renderTemplate(w, t.passwordTmpl, data) } diff --git a/storage/ent/client/main.go b/storage/ent/client/main.go index bc4c1600ac..8f16e4e6fd 100644 --- a/storage/ent/client/main.go +++ b/storage/ent/client/main.go @@ -15,6 +15,11 @@ import ( "github.com/dexidp/dex/storage/ent/db/migrate" ) +func (*Database) GetInvalidLoginAttempt(username_conn_id string) (storage.InvalidLoginAttempt, error) +func (*Database) CreateInvalidLoginAttempt(client storage.InvalidLoginAttempt) error +func (*Database) DeleteInvalidLoginAttempt(username_conn_id string) error +func (*Database) UpdateInvalidLoginAttempt(username_conn_id string, updater func(storage.InvalidLoginAttempt) (storage.InvalidLoginAttempt, error)) error + var _ storage.Storage = (*Database)(nil) type Database struct { diff --git a/storage/etcd/etcd.go b/storage/etcd/etcd.go index 13e815ec8d..7996feed02 100644 --- a/storage/etcd/etcd.go +++ b/storage/etcd/etcd.go @@ -14,16 +14,17 @@ import ( ) const ( - clientPrefix = "client/" - authCodePrefix = "auth_code/" - refreshTokenPrefix = "refresh_token/" - authRequestPrefix = "auth_req/" - passwordPrefix = "password/" - offlineSessionPrefix = "offline_session/" - connectorPrefix = "connector/" - keysName = "openid-connect-keys" - deviceRequestPrefix = "device_req/" - deviceTokenPrefix = "device_token/" + clientPrefix = "client/" + authCodePrefix = "auth_code/" + refreshTokenPrefix = "refresh_token/" + authRequestPrefix = "auth_req/" + passwordPrefix = "password/" + InvalidLoginAttemptPrefix = "invalid_login_attempt/" + offlineSessionPrefix = "offline_session/" + connectorPrefix = "connector/" + keysName = "openid-connect-keys" + deviceRequestPrefix = "device_req/" + deviceTokenPrefix = "device_token/" // defaultStorageTimeout will be applied to all storage's operations. defaultStorageTimeout = 5 * time.Second @@ -287,6 +288,12 @@ func (c *conn) CreatePassword(p storage.Password) error { return c.txnCreate(ctx, passwordPrefix+strings.ToLower(p.Email), p) } +func (c *conn) CreateInvalidLoginAttempt(u storage.InvalidLoginAttempt) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout) + defer cancel() + return c.txnCreate(ctx, InvalidLoginAttemptPrefix+strings.ToLower(u.UsernameConnID), u) +} + func (c *conn) GetPassword(email string) (p storage.Password, err error) { ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout) defer cancel() @@ -294,6 +301,13 @@ func (c *conn) GetPassword(email string) (p storage.Password, err error) { return p, err } +func (c *conn) GetInvalidLoginAttempt(username_conn_id string) (u storage.InvalidLoginAttempt, err error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout) + defer cancel() + err = c.getKey(ctx, keyUsername(InvalidLoginAttemptPrefix, username_conn_id), &u) + return u, err +} + func (c *conn) UpdatePassword(email string, updater func(p storage.Password) (storage.Password, error)) error { ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout) defer cancel() @@ -312,12 +326,36 @@ func (c *conn) UpdatePassword(email string, updater func(p storage.Password) (st }) } +func (c *conn) UpdateInvalidLoginAttempt(username_conn_id string, updater func(p storage.InvalidLoginAttempt) (storage.InvalidLoginAttempt, error)) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout) + defer cancel() + return c.txnUpdate(ctx, keyEmail(InvalidLoginAttemptPrefix, username_conn_id), func(currentValue []byte) ([]byte, error) { + var current storage.InvalidLoginAttempt + if len(currentValue) > 0 { + if err := json.Unmarshal(currentValue, ¤t); err != nil { + return nil, err + } + } + updated, err := updater(current) + if err != nil { + return nil, err + } + return json.Marshal(updated) + }) +} + func (c *conn) DeletePassword(email string) error { ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout) defer cancel() return c.deleteKey(ctx, keyEmail(passwordPrefix, email)) } +func (c *conn) DeleteInvalidLoginAttempt(username_conn_id string) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout) + defer cancel() + return c.deleteKey(ctx, keyEmail(InvalidLoginAttemptPrefix, username_conn_id)) +} + func (c *conn) ListPasswords() (passwords []storage.Password, err error) { ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout) defer cancel() @@ -562,8 +600,9 @@ func (c *conn) txnUpdate(ctx context.Context, key string, update func(current [] return nil } -func keyID(prefix, id string) string { return prefix + id } -func keyEmail(prefix, email string) string { return prefix + strings.ToLower(email) } +func keyID(prefix, id string) string { return prefix + id } +func keyEmail(prefix, email string) string { return prefix + strings.ToLower(email) } +func keyUsername(prefix, username string) string { return prefix + strings.ToLower(username) } func keySession(userID, connID string) string { return offlineSessionPrefix + strings.ToLower(userID+"|"+connID) } diff --git a/storage/kubernetes/storage.go b/storage/kubernetes/storage.go index 0979f14ac0..df27ff1245 100644 --- a/storage/kubernetes/storage.go +++ b/storage/kubernetes/storage.go @@ -15,29 +15,31 @@ import ( ) const ( - kindAuthCode = "AuthCode" - kindAuthRequest = "AuthRequest" - kindClient = "OAuth2Client" - kindRefreshToken = "RefreshToken" - kindKeys = "SigningKey" - kindPassword = "Password" - kindOfflineSessions = "OfflineSessions" - kindConnector = "Connector" - kindDeviceRequest = "DeviceRequest" - kindDeviceToken = "DeviceToken" + kindAuthCode = "AuthCode" + kindAuthRequest = "AuthRequest" + kindClient = "OAuth2Client" + kindRefreshToken = "RefreshToken" + kindKeys = "SigningKey" + kindPassword = "Password" + kindInvalidLoginAttempt = "InvalidLoginAttempt" + kindOfflineSessions = "OfflineSessions" + kindConnector = "Connector" + kindDeviceRequest = "DeviceRequest" + kindDeviceToken = "DeviceToken" ) const ( - resourceAuthCode = "authcodes" - resourceAuthRequest = "authrequests" - resourceClient = "oauth2clients" - resourceRefreshToken = "refreshtokens" - resourceKeys = "signingkeies" // Kubernetes attempts to pluralize. - resourcePassword = "passwords" - resourceOfflineSessions = "offlinesessionses" // Again attempts to pluralize. - resourceConnector = "connectors" - resourceDeviceRequest = "devicerequests" - resourceDeviceToken = "devicetokens" + resourceAuthCode = "authcodes" + resourceAuthRequest = "authrequests" + resourceClient = "oauth2clients" + resourceRefreshToken = "refreshtokens" + resourceKeys = "signingkeies" // Kubernetes attempts to pluralize. + resourcePassword = "passwords" + resourceInvalidLoginAttempt = "InvalidLoginAttempt" + resourceOfflineSessions = "offlinesessionses" // Again attempts to pluralize. + resourceConnector = "connectors" + resourceDeviceRequest = "devicerequests" + resourceDeviceToken = "devicetokens" ) const ( @@ -248,6 +250,10 @@ func (cli *client) CreatePassword(p storage.Password) error { return cli.post(resourcePassword, cli.fromStoragePassword(p)) } +func (cli *client) CreateInvalidLoginAttempt(u storage.InvalidLoginAttempt) error { + return cli.post(resourceInvalidLoginAttempt, cli.fromStorageInvalidLoginAttempt(u)) +} + func (cli *client) CreateRefresh(r storage.RefreshToken) error { return cli.post(resourceRefreshToken, cli.fromStorageRefreshToken(r)) } @@ -304,6 +310,14 @@ func (cli *client) GetPassword(email string) (storage.Password, error) { return toStoragePassword(p), nil } +func (cli *client) GetInvalidLoginAttempt(username_conn_id string) (storage.InvalidLoginAttempt, error) { + u, err := cli.getInvalidLoginAttempt(username_conn_id) + if err != nil { + return storage.InvalidLoginAttempt{}, err + } + return toStorageInvalidLoginAttempt(u), nil +} + func (cli *client) getPassword(email string) (Password, error) { // TODO(ericchiang): Figure out whose job it is to lowercase emails. email = strings.ToLower(email) @@ -318,6 +332,19 @@ func (cli *client) getPassword(email string) (Password, error) { return p, nil } +func (cli *client) getInvalidLoginAttempt(username_conn_id string) (InvalidLoginAttempt, error) { + username_conn_id = strings.ToLower(username_conn_id) + var u InvalidLoginAttempt + name := cli.idToName(username_conn_id) + if err := cli.get(resourceInvalidLoginAttempt, name, &u); err != nil { + return InvalidLoginAttempt{}, err + } + if username_conn_id != u.UsernameConnID { + return InvalidLoginAttempt{}, fmt.Errorf("get InvalidLoginAttempt: username_conn_id %q mapped to InvalidLoginAttempt with username_conn_id %q", username_conn_id, u.UsernameConnID) + } + return u, nil +} + func (cli *client) GetKeys() (storage.Keys, error) { var keys Keys if err := cli.get(resourceKeys, keysName, &keys); err != nil { @@ -437,6 +464,15 @@ func (cli *client) DeletePassword(email string) error { return cli.delete(resourcePassword, p.ObjectMeta.Name) } +func (cli *client) DeleteInvalidLoginAttempt(username_conn_id string) error { + // Check for hash collision. + u, err := cli.getInvalidLoginAttempt(username_conn_id) + if err != nil { + return err + } + return cli.delete(resourceInvalidLoginAttempt, u.ObjectMeta.Name) +} + func (cli *client) DeleteOfflineSessions(userID string, connID string) error { // Check for hash collision. o, err := cli.getOfflineSessions(userID, connID) @@ -511,6 +547,24 @@ func (cli *client) UpdatePassword(email string, updater func(old storage.Passwor return cli.put(resourcePassword, p.ObjectMeta.Name, newPassword) } +func (cli *client) UpdateInvalidLoginAttempt(username_conn_id string, updater func(old storage.InvalidLoginAttempt) (storage.InvalidLoginAttempt, error)) error { + u, err := cli.getInvalidLoginAttempt(username_conn_id) + if err != nil { + return err + } + + updated, err := updater(toStorageInvalidLoginAttempt(u)) + if err != nil { + return err + } + updated.InvalidLoginAttemptsCount = u.InvalidLoginAttemptsCount + updated.UpdatedAt = u.UpdatedAt + + newInvalidLoginAttempt := cli.fromStorageInvalidLoginAttempt(updated) + newInvalidLoginAttempt.ObjectMeta = u.ObjectMeta + return cli.put(resourceInvalidLoginAttempt, u.ObjectMeta.Name, newInvalidLoginAttempt) +} + func (cli *client) UpdateOfflineSessions(userID string, connID string, updater func(old storage.OfflineSessions) (storage.OfflineSessions, error)) error { return retryOnConflict(context.TODO(), func() error { o, err := cli.getOfflineSessions(userID, connID) diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index a5ec29afd4..f3f4639310 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -436,6 +436,21 @@ type Password struct { UserID string `json:"userID,omitempty"` } +// BlockedUser is a mirrored struct from the stroage with JSON struct tags and +// Kubernetes type metadata. +type InvalidLoginAttempt struct { + k8sapi.TypeMeta `json:",inline"` + k8sapi.ObjectMeta `json:"metadata,omitempty"` + + // The Kubernetes name is actually an encoded version of this value. + // + // This field is IMMUTABLE. Do not change. + UsernameConnID string `json:"username_conn_id,omitempty"` + + InvalidLoginAttemptsCount int32 `json:"invalid_login_attempts_count,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + // PasswordList is a list of Passwords. type PasswordList struct { k8sapi.TypeMeta `json:",inline"` @@ -470,6 +485,31 @@ func toStoragePassword(p Password) storage.Password { } } +func (cli *client) fromStorageInvalidLoginAttempt(u storage.InvalidLoginAttempt) InvalidLoginAttempt { + username_conn_id := strings.ToLower(u.UsernameConnID) + return InvalidLoginAttempt{ + TypeMeta: k8sapi.TypeMeta{ + Kind: kindInvalidLoginAttempt, + APIVersion: cli.apiVersion, + }, + ObjectMeta: k8sapi.ObjectMeta{ + Name: cli.idToName(username_conn_id), + Namespace: cli.namespace, + }, + UsernameConnID: username_conn_id, + InvalidLoginAttemptsCount: u.InvalidLoginAttemptsCount, + UpdatedAt: u.UpdatedAt, + } +} + +func toStorageInvalidLoginAttempt(u InvalidLoginAttempt) storage.InvalidLoginAttempt { + return storage.InvalidLoginAttempt{ + UsernameConnID: u.UsernameConnID, + InvalidLoginAttemptsCount: u.InvalidLoginAttemptsCount, + UpdatedAt: u.UpdatedAt, + } +} + // AuthCode is a mirrored struct from storage with JSON struct tags and // Kubernetes type metadata. type AuthCode struct { diff --git a/storage/memory/memory.go b/storage/memory/memory.go index a940665714..6f60406a2e 100644 --- a/storage/memory/memory.go +++ b/storage/memory/memory.go @@ -13,16 +13,17 @@ import ( // New returns an in memory storage. func New(logger log.Logger) storage.Storage { return &memStorage{ - clients: make(map[string]storage.Client), - authCodes: make(map[string]storage.AuthCode), - refreshTokens: make(map[string]storage.RefreshToken), - authReqs: make(map[string]storage.AuthRequest), - passwords: make(map[string]storage.Password), - offlineSessions: make(map[offlineSessionID]storage.OfflineSessions), - connectors: make(map[string]storage.Connector), - deviceRequests: make(map[string]storage.DeviceRequest), - deviceTokens: make(map[string]storage.DeviceToken), - logger: logger, + clients: make(map[string]storage.Client), + authCodes: make(map[string]storage.AuthCode), + refreshTokens: make(map[string]storage.RefreshToken), + authReqs: make(map[string]storage.AuthRequest), + passwords: make(map[string]storage.Password), + InvalidLoginAttempt: make(map[string]storage.InvalidLoginAttempt), + offlineSessions: make(map[offlineSessionID]storage.OfflineSessions), + connectors: make(map[string]storage.Connector), + deviceRequests: make(map[string]storage.DeviceRequest), + deviceTokens: make(map[string]storage.DeviceToken), + logger: logger, } } @@ -40,15 +41,16 @@ func (c *Config) Open(logger log.Logger) (storage.Storage, error) { type memStorage struct { mu sync.Mutex - clients map[string]storage.Client - authCodes map[string]storage.AuthCode - refreshTokens map[string]storage.RefreshToken - authReqs map[string]storage.AuthRequest - passwords map[string]storage.Password - offlineSessions map[offlineSessionID]storage.OfflineSessions - connectors map[string]storage.Connector - deviceRequests map[string]storage.DeviceRequest - deviceTokens map[string]storage.DeviceToken + clients map[string]storage.Client + authCodes map[string]storage.AuthCode + refreshTokens map[string]storage.RefreshToken + authReqs map[string]storage.AuthRequest + passwords map[string]storage.Password + InvalidLoginAttempt map[string]storage.InvalidLoginAttempt + offlineSessions map[offlineSessionID]storage.OfflineSessions + connectors map[string]storage.Connector + deviceRequests map[string]storage.DeviceRequest + deviceTokens map[string]storage.DeviceToken keys storage.Keys @@ -154,6 +156,18 @@ func (s *memStorage) CreatePassword(p storage.Password) (err error) { return } +func (s *memStorage) CreateInvalidLoginAttempt(u storage.InvalidLoginAttempt) (err error) { + lowerUsernameConnID := strings.ToLower(u.UsernameConnID) + s.tx(func() { + if _, ok := s.InvalidLoginAttempt[lowerUsernameConnID]; ok { + err = storage.ErrAlreadyExists + } else { + s.InvalidLoginAttempt[lowerUsernameConnID] = u + } + }) + return +} + func (s *memStorage) CreateOfflineSessions(o storage.OfflineSessions) (err error) { id := offlineSessionID{ userID: o.UserID, @@ -202,6 +216,17 @@ func (s *memStorage) GetPassword(email string) (p storage.Password, err error) { return } +func (s *memStorage) GetInvalidLoginAttempt(username string) (u storage.InvalidLoginAttempt, err error) { + username = strings.ToLower(username) + s.tx(func() { + var ok bool + if u, ok = s.InvalidLoginAttempt[username]; !ok { + err = storage.ErrNotFound + } + }) + return +} + func (s *memStorage) GetClient(id string) (client storage.Client, err error) { s.tx(func() { var ok bool @@ -312,6 +337,18 @@ func (s *memStorage) DeletePassword(email string) (err error) { return } +func (s *memStorage) DeleteInvalidLoginAttempt(username string) (err error) { + username = strings.ToLower(username) + s.tx(func() { + if _, ok := s.InvalidLoginAttempt[username]; !ok { + err = storage.ErrNotFound + return + } + delete(s.InvalidLoginAttempt, username) + }) + return +} + func (s *memStorage) DeleteClient(id string) (err error) { s.tx(func() { if _, ok := s.clients[id]; !ok { @@ -435,6 +472,21 @@ func (s *memStorage) UpdatePassword(email string, updater func(p storage.Passwor return } +func (s *memStorage) UpdateInvalidLoginAttempt(username string, updater func(p storage.InvalidLoginAttempt) (storage.InvalidLoginAttempt, error)) (err error) { + username = strings.ToLower(username) + s.tx(func() { + req, ok := s.InvalidLoginAttempt[username] + if !ok { + err = storage.ErrNotFound + return + } + if req, err = updater(req); err == nil { + s.InvalidLoginAttempt[username] = req + } + }) + return +} + func (s *memStorage) UpdateRefreshToken(id string, updater func(p storage.RefreshToken) (storage.RefreshToken, error)) (err error) { s.tx(func() { r, ok := s.refreshTokens[id] diff --git a/storage/memory/static_test.go b/storage/memory/static_test.go index 8513e0ee89..899eab8bfb 100644 --- a/storage/memory/static_test.go +++ b/storage/memory/static_test.go @@ -5,6 +5,7 @@ import ( "os" "strings" "testing" + "time" "github.com/sirupsen/logrus" @@ -210,6 +211,106 @@ func TestStaticPasswords(t *testing.T) { } } +func TestStaticBlockedUsers(t *testing.T) { + logger := &logrus.Logger{ + Out: os.Stderr, + Formatter: &logrus.TextFormatter{DisableColors: true}, + Level: logrus.DebugLevel, + } + backing := New(logger) + + u1 := storage.InvalidLoginAttempt{UsernameConnID: "foo_secret:ldap", InvalidLoginAttemptsCount: 0} + u2 := storage.InvalidLoginAttempt{UsernameConnID: "bar_secret:local", InvalidLoginAttemptsCount: 4} + u3 := storage.InvalidLoginAttempt{UsernameConnID: "spam_secret:local", InvalidLoginAttemptsCount: 2} + u4 := storage.InvalidLoginAttempt{UsernameConnID: "Spam_secret:ldap", InvalidLoginAttemptsCount: 5} + + backing.CreateInvalidLoginAttempt(u1) + s := storage.WithStaticInvalidLoginAttempt(backing, []storage.InvalidLoginAttempt{u2}, logger) + + tests := []struct { + name string + action func() error + wantErr bool + }{ + { + name: "get InvalidLoginAttempt from static storage", + action: func() error { + _, err := s.GetInvalidLoginAttempt(u2.UsernameConnID) + return err + }, + }, + { + name: "get InvalidLoginAttempt from backing storage", + action: func() error { + _, err := s.GetInvalidLoginAttempt(u1.UsernameConnID) + return err + }, + }, + { + name: "get InvalidLoginAttempt from static storage with casing", + action: func() error { + _, err := s.GetInvalidLoginAttempt(strings.ToUpper(u2.UsernameConnID)) + return err + }, + }, + { + name: "update static InvalidLoginAttempt", + action: func() error { + updater := func(u storage.InvalidLoginAttempt) (storage.InvalidLoginAttempt, error) { + u.UsernameConnID = "new_" + u.UsernameConnID + return u, nil + } + return s.UpdateInvalidLoginAttempt(u2.UsernameConnID, updater) + }, + wantErr: true, + }, + { + name: "update non-static InvalidLoginAttempt", + action: func() error { + updater := func(u storage.InvalidLoginAttempt) (storage.InvalidLoginAttempt, error) { + u.InvalidLoginAttemptsCount = u.InvalidLoginAttemptsCount + 1 + u.UpdatedAt = time.Now() + return u, nil + } + return s.UpdateInvalidLoginAttempt(u1.UsernameConnID, updater) + }, + }, + { + name: "create InvalidLoginAttempt", + action: func() error { + if err := s.CreateInvalidLoginAttempt(u4); err != nil { + return err + } + return s.CreateInvalidLoginAttempt(u3) + }, + wantErr: true, + }, + { + name: "get InvalidLoginAttempt", + action: func() error { + u, err := s.GetInvalidLoginAttempt(u4.UsernameConnID) + if err != nil { + return err + } + if strings.Compare(u.UsernameConnID, u4.UsernameConnID) != 0 { + return fmt.Errorf("expected %s InvalidLoginAttempt got %s", u4.UsernameConnID, u.UsernameConnID) + } + return nil + }, + }, + } + + for _, tc := range tests { + err := tc.action() + if err != nil && !tc.wantErr { + t.Errorf("%s: %v", tc.name, err) + } + if err == nil && tc.wantErr { + t.Errorf("%s: expected error, didn't get one", tc.name) + } + } +} + func TestStaticConnectors(t *testing.T) { logger := &logrus.Logger{ Out: os.Stderr, diff --git a/storage/sql/crud.go b/storage/sql/crud.go index 1583c17741..46969bfe3d 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -522,6 +522,32 @@ func (c *conn) UpdateClient(id string, updater func(old storage.Client) (storage }) } +func (c *conn) UpdateInvalidLoginAttempt(username_conn_id string, updater func(old storage.InvalidLoginAttempt) (storage.InvalidLoginAttempt, error)) error { + return c.ExecTx(func(tx *trans) error { + u, err := getInvalidLoginAttempt(tx, username_conn_id) + if err != nil { + return err + } + nu, err := updater(u) + if err != nil { + return err + } + + _, err = tx.Exec(` + update invalid_login_attempts + set + invalid_login_attempts_count = $1, + updated_at = $2 + where username_conn_id = $3; + `, nu.InvalidLoginAttemptsCount, nu.UpdatedAt, username_conn_id, + ) + if err != nil { + return fmt.Errorf("update invalid_login_attempts: %v", err) + } + return nil + }) +} + func (c *conn) CreateClient(cli storage.Client) error { _, err := c.Exec(` insert into client ( @@ -541,6 +567,24 @@ func (c *conn) CreateClient(cli storage.Client) error { return nil } +func (c *conn) CreateInvalidLoginAttempt(u storage.InvalidLoginAttempt) error { + _, err := c.Exec(` + insert into invalid_login_attempts ( + username_conn_id, invalid_login_attempts_count, updated_at + ) + values ($1, $2, $3); + `, + strings.ToLower(u.UsernameConnID), u.InvalidLoginAttemptsCount, u.UpdatedAt, + ) + if err != nil { + if c.alreadyExistsCheck(err) { + return storage.ErrAlreadyExists + } + return fmt.Errorf("insert into invalid_login_attempts: %v", err) + } + return nil +} + func getClient(q querier, id string) (storage.Client, error) { return scanClient(q.QueryRow(` select @@ -643,6 +687,22 @@ func (c *conn) GetPassword(email string) (storage.Password, error) { return getPassword(c, email) } +func (c *conn) GetInvalidLoginAttempt(username_conn_id string) (storage.InvalidLoginAttempt, error) { + u, err := getInvalidLoginAttempt(c, username_conn_id) + if err == storage.ErrNotFound { + return u, nil + } + return u, err +} + +func getInvalidLoginAttempt(q querier, username_conn_id string) (u storage.InvalidLoginAttempt, err error) { + return scanInvalidLoginAttempt(q.QueryRow(` + select + username_conn_id, invalid_login_attempts_count, updated_at + from invalid_login_attempts where username_conn_id = $1; + `, strings.ToLower(username_conn_id))) +} + func getPassword(q querier, email string) (p storage.Password, err error) { return scanPassword(q.QueryRow(` select @@ -689,6 +749,19 @@ func scanPassword(s scanner) (p storage.Password, err error) { return p, nil } +func scanInvalidLoginAttempt(s scanner) (u storage.InvalidLoginAttempt, err error) { + err = s.Scan( + &u.UsernameConnID, &u.InvalidLoginAttemptsCount, &u.UpdatedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return u, storage.ErrNotFound + } + return u, fmt.Errorf("select InvalidLoginAttempt: %v", err) + } + return u, nil +} + func (c *conn) CreateOfflineSessions(s storage.OfflineSessions) error { _, err := c.Exec(` insert into offline_session ( @@ -869,6 +942,9 @@ func (c *conn) DeleteRefresh(id string) error { return c.delete("refresh_tok func (c *conn) DeletePassword(email string) error { return c.delete("password", "email", strings.ToLower(email)) } +func (c *conn) DeleteInvalidLoginAttempt(username_conn_id string) error { + return c.delete("invalid_login_attempts", "username_conn_id", strings.ToLower(username_conn_id)) +} func (c *conn) DeleteConnector(id string) error { return c.delete("connector", "id", id) } func (c *conn) DeleteOfflineSessions(userID string, connID string) error { @@ -1002,7 +1078,7 @@ func (c *conn) UpdateDeviceToken(deviceCode string, updater func(old storage.Dev _, err = tx.Exec(` update device_token set - status = $1, + status = $1, token = $2, last_request = $3, poll_interval = $4, diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 83e9c20d94..44e7dca02e 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -298,4 +298,15 @@ var migrations = []migration{ add column hmac_key bytea;`, }, }, + { + stmts: []string{ + ` + create table if not exists invalid_login_attempts ( + username_conn_id text not null primary key, + invalid_login_attempts_count integer not null default 1, + updated_at timestamptz not null default '0001-01-01 00:00:00 UTC' + );`, + }, + flavor: &flavorPostgres, + }, } diff --git a/storage/static.go b/storage/static.go index 806b61f9cd..f6240c5f30 100644 --- a/storage/static.go +++ b/storage/static.go @@ -161,6 +161,66 @@ func (s staticPasswordsStorage) UpdatePassword(email string, updater func(old Pa return s.Storage.UpdatePassword(email, updater) } +type staticInvalidLoginAttemptStorage struct { + Storage + + // A read-only set of InvalidLoginAttempt. + InvalidLoginAttempt []InvalidLoginAttempt + // A map of InvalidLoginAttempt that is indexed by lower-case username_conn_id + InvalidLoginAttemptByUsernameConnID map[string]InvalidLoginAttempt + + logger log.Logger +} + +// WithStaticInvalidLoginAttempt returns a storage with a read-only set of InvalidLoginAttempt. +func WithStaticInvalidLoginAttempt(s Storage, staticInvalidLoginAttempt []InvalidLoginAttempt, logger log.Logger) Storage { + InvalidLoginAttemptByUsernameConnID := make(map[string]InvalidLoginAttempt, len(staticInvalidLoginAttempt)) + for _, u := range staticInvalidLoginAttempt { + // Enable case insensitive user comparison. + lowerUsernameConnID := strings.ToLower(u.UsernameConnID) + if _, ok := InvalidLoginAttemptByUsernameConnID[lowerUsernameConnID]; ok { + logger.Errorf("Attempting to create StaticInvalidLoginAttempt with the same user: %s", u.UsernameConnID) + } + InvalidLoginAttemptByUsernameConnID[lowerUsernameConnID] = u + } + + return staticInvalidLoginAttemptStorage{s, staticInvalidLoginAttempt, InvalidLoginAttemptByUsernameConnID, logger} +} + +func (s staticInvalidLoginAttemptStorage) isStatic(username_conn_id string) bool { + _, ok := s.InvalidLoginAttemptByUsernameConnID[strings.ToLower(username_conn_id)] + return ok +} + +func (s staticInvalidLoginAttemptStorage) GetBlockedUser(username_conn_id string) (InvalidLoginAttempt, error) { + username_conn_id = strings.ToLower(username_conn_id) + if username_conn_id, ok := s.InvalidLoginAttemptByUsernameConnID[username_conn_id]; ok { + return username_conn_id, nil + } + return s.Storage.GetInvalidLoginAttempt(username_conn_id) +} + +func (s staticInvalidLoginAttemptStorage) CreateInvalidLoginAttempt(u InvalidLoginAttempt) error { + if s.isStatic(u.UsernameConnID) { + return errors.New("static username_conn_id: read-only cannot create InvalidLoginAttempt") + } + return s.Storage.CreateInvalidLoginAttempt(u) +} + +func (s staticInvalidLoginAttemptStorage) DeleteInvalidLoginAttempt(username_conn_id string) error { + if s.isStatic(username_conn_id) { + return errors.New("static username_conn_id: read-only cannot delete InvalidLoginAttempt") + } + return s.Storage.DeleteInvalidLoginAttempt(username_conn_id) +} + +func (s staticInvalidLoginAttemptStorage) UpdateInvalidLoginAttempt(username_conn_id string, updater func(old InvalidLoginAttempt) (InvalidLoginAttempt, error)) error { + if s.isStatic(username_conn_id) { + return errors.New("static username_conn_id: read-only cannot update InvalidLoginAttempt") + } + return s.Storage.UpdateInvalidLoginAttempt(username_conn_id, updater) +} + // staticConnectorsStorage represents a storage with read-only set of connectors. type staticConnectorsStorage struct { Storage diff --git a/storage/storage.go b/storage/storage.go index 198a70c8e6..4536cb6503 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -69,6 +69,12 @@ func (g *GCResult) IsEmpty() bool { g.DeviceTokens == 0 } +type InvalidLoginAttempt struct { + UsernameConnID string + InvalidLoginAttemptsCount int32 + UpdatedAt time.Time +} + // Storage is the storage interface used by the server. Implementations are // required to be able to perform atomic compare-and-swap updates and either // support timezones or standardize on UTC. @@ -81,6 +87,7 @@ type Storage interface { CreateAuthCode(c AuthCode) error CreateRefresh(r RefreshToken) error CreatePassword(p Password) error + CreateInvalidLoginAttempt(u InvalidLoginAttempt) error CreateOfflineSessions(s OfflineSessions) error CreateConnector(c Connector) error CreateDeviceRequest(d DeviceRequest) error @@ -98,6 +105,7 @@ type Storage interface { GetConnector(id string) (Connector, error) GetDeviceRequest(userCode string) (DeviceRequest, error) GetDeviceToken(deviceCode string) (DeviceToken, error) + GetInvalidLoginAttempt(username_conn_id string) (InvalidLoginAttempt, error) ListClients() ([]Client, error) ListRefreshTokens() ([]RefreshToken, error) @@ -110,6 +118,7 @@ type Storage interface { DeleteClient(id string) error DeleteRefresh(id string) error DeletePassword(email string) error + DeleteInvalidLoginAttempt(username_conn_id string) error DeleteOfflineSessions(userID string, connID string) error DeleteConnector(id string) error @@ -132,6 +141,7 @@ type Storage interface { UpdateAuthRequest(id string, updater func(a AuthRequest) (AuthRequest, error)) error UpdateRefreshToken(id string, updater func(r RefreshToken) (RefreshToken, error)) error UpdatePassword(email string, updater func(p Password) (Password, error)) error + UpdateInvalidLoginAttempt(username_conn_id string, updater func(u InvalidLoginAttempt) (InvalidLoginAttempt, error)) error UpdateOfflineSessions(userID string, connID string, updater func(s OfflineSessions) (OfflineSessions, error)) error UpdateConnector(id string, updater func(c Connector) (Connector, error)) error UpdateDeviceToken(deviceCode string, updater func(t DeviceToken) (DeviceToken, error)) error