@@ -21,6 +21,7 @@ import (
2121 "path/filepath"
2222 "strconv"
2323 "strings"
24+ "sync"
2425 "time"
2526
2627 "github.com/gorilla/mux"
@@ -34,10 +35,12 @@ import (
3435 "storj.io/common/http/requestid"
3536 "storj.io/common/memory"
3637 "storj.io/common/storj"
38+ "storj.io/common/uuid"
3739 "storj.io/storj/private/web"
3840 "storj.io/storj/satellite/abtesting"
3941 "storj.io/storj/satellite/analytics"
4042 "storj.io/storj/satellite/console"
43+ "storj.io/storj/satellite/console/consoleauth"
4144 "storj.io/storj/satellite/console/consoleauth/csrf"
4245 "storj.io/storj/satellite/console/consoleauth/sso"
4346 "storj.io/storj/satellite/console/consoleservice"
@@ -131,6 +134,7 @@ type Config struct {
131134 ZipDownloadLimit int `help:"maximum number of objects allowed for a zip format download" default:"1000"`
132135 LiveCheckBadPasswords bool `help:"whether to check if provided password is in bad passwords list" default:"false"`
133136 UseGeneratedPrivateAPI bool `help:"whether to use generated private API" default:"false"`
137+ GhostSessionCheckEnabled bool `help:"whether to enable ghost session detection and notification" default:"false"`
134138
135139 OauthCodeExpiry time.Duration `help:"how long oauth authorization codes are issued for" default:"10m"`
136140 OauthAccessTokenExpiry time.Duration `help:"how long oauth access tokens are issued for" default:"24h"`
@@ -193,6 +197,11 @@ type Server struct {
193197 usagePrices payments.ProjectUsagePriceModel
194198
195199 errorTemplate * template.Template
200+
201+ // ghostSessionEmailSent tracks when ghost session emails were last sent to users.
202+ // Key: userID, Value: timestamp of last email sent
203+ ghostSessionEmailSent map [uuid.UUID ]time.Time
204+ ghostSessionEmailMutex sync.Mutex
196205}
197206
198207// NewServer creates new instance of console server.
@@ -226,6 +235,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, cons
226235 minimumChargeConfig : minimumChargeConfig ,
227236 usagePrices : usagePrices ,
228237 objectLockAndVersioningConfig : objectLockAndVersioningConfig ,
238+ ghostSessionEmailSent : make (map [uuid.UUID ]time.Time ),
229239 }
230240
231241 logger .Debug ("Starting Satellite Console server." , zap .Stringer ("Address" , server .listener .Addr ()))
@@ -561,6 +571,10 @@ func (server *Server) Run(ctx context.Context) (err error) {
561571 server .addCardRateLimiter .Run (ctx )
562572 return nil
563573 })
574+ group .Go (func () error {
575+ server .runGhostSessionCacheCleanup (ctx )
576+ return nil
577+ })
564578 group .Go (func () error {
565579 defer cancel ()
566580 err := server .server .Serve (server .listener )
@@ -879,16 +893,83 @@ func (server *Server) withAuth(handler http.Handler) http.Handler {
879893 return
880894 }
881895
882- newCtx , err := server .service .TokenAuth (ctx , tokenInfo .Token , time .Now ())
896+ newCtx , session , err := server .service .TokenAuth (ctx , tokenInfo .Token , time .Now ())
883897 if err != nil {
884898 return
885899 }
886900 ctx = newCtx
887901
902+ if server .config .GhostSessionCheckEnabled {
903+ if gsErr := server .checkGhostSession (ctx , r , session ); gsErr != nil {
904+ server .log .Error ("failed to check ghost session" , zap .Error (gsErr ))
905+ }
906+ }
907+
888908 handler .ServeHTTP (w , r .Clone (ctx ))
889909 })
890910}
891911
912+ func (server * Server ) checkGhostSession (ctx context.Context , r * http.Request , session * consoleauth.WebappSession ) error {
913+ if session == nil {
914+ return nil
915+ }
916+
917+ ip , err := web .GetRequestIP (r )
918+ if err != nil {
919+ return err
920+ }
921+ userAgent := r .UserAgent ()
922+
923+ if session .UserAgent != userAgent || session .Address != ip {
924+ user , err := console .GetUser (ctx )
925+ if err != nil {
926+ return err
927+ }
928+
929+ // Atomic check-and-send operation to prevent race conditions.
930+ server .ghostSessionEmailMutex .Lock ()
931+ defer server .ghostSessionEmailMutex .Unlock ()
932+
933+ lastSent , exists := server .ghostSessionEmailSent [user .ID ]
934+ if exists && time .Since (lastSent ) < 2 * time .Hour {
935+ return nil // Email sent recently, skip.
936+ }
937+
938+ server .ghostSessionEmailSent [user .ID ] = time .Now ()
939+ server .hubspotMailService .SendAsync (ctx , & hubspotmails.SendEmailRequest {
940+ Kind : hubspotmails .GhostSessionWarning ,
941+ To : user .Email ,
942+ })
943+ }
944+
945+ return nil
946+ }
947+
948+ // runGhostSessionCacheCleanup periodically cleans up old ghost session email timestamps to prevent memory leaks.
949+ func (server * Server ) runGhostSessionCacheCleanup (ctx context.Context ) {
950+ ticker := time .NewTicker (24 * time .Hour ) // Clean up daily.
951+ defer ticker .Stop ()
952+
953+ for {
954+ select {
955+ case <- ctx .Done ():
956+ return
957+ case <- ticker .C :
958+ // Remove timestamps older than 24 hours.
959+ cutoff := time .Now ().Add (- 24 * time .Hour )
960+ server .ghostSessionEmailMutex .Lock ()
961+ for userID , timestamp := range server .ghostSessionEmailSent {
962+ if timestamp .Before (cutoff ) {
963+ delete (server .ghostSessionEmailSent , userID )
964+ }
965+ }
966+ server .ghostSessionEmailMutex .Unlock ()
967+
968+ server .log .Info ("Cleaned up old ghost session email timestamps" )
969+ }
970+ }
971+ }
972+
892973// withRequest ensures the http request itself is reachable from the context.
893974func (server * Server ) withRequest (handler http.Handler ) http.Handler {
894975 return http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
@@ -1493,7 +1574,8 @@ func (a *apiAuth) cookieAuth(ctx context.Context, r *http.Request) (context.Cont
14931574 return nil , err
14941575 }
14951576
1496- return a .server .service .TokenAuth (ctx , tokenInfo .Token , time .Now ())
1577+ newCtx , _ , err := a .server .service .TokenAuth (ctx , tokenInfo .Token , time .Now ())
1578+ return newCtx , err
14971579}
14981580
14991581// cookieAuth returns an authenticated context by api key.
0 commit comments