RedisLoginAttemptStore.java

1
package com.rbctcsworld.ecommerce.auth;
2
3
import org.slf4j.Logger;
4
import org.slf4j.LoggerFactory;
5
6
import java.time.Clock;
7
import java.time.Duration;
8
import java.time.Instant;
9
import java.util.concurrent.atomic.AtomicLong;
10
11
/**
12
 * Failed logins counted in Redis, shared by every backend instance and kept across restarts.
13
 *
14
 *  - Keys hold a SHA-256 of the e-mail, never the e-mail itself: rbc:login:fail:{hash}, rbc:login:lock:{hash}.
15
 *  - Redis expires the keys (window and lock time), so nothing has to be cleaned up.
16
 *  - Redis unreachable: the login keeps working and the in-memory store takes over (fail-open with a
17
 *    fallback). Choosing to keep the shop usable over a hard 503; the fallback still stops brute force on
18
 *    each instance. One warning per minute in the log, not one per login.
19
 */
20
class RedisLoginAttemptStore implements LoginAttemptStore {
21
22
    private static final Logger log = LoggerFactory.getLogger(RedisLoginAttemptStore.class);
23
    static final String PREFIX = "rbc:login:";
24
25
    private final RedisOps redis;
26
    private final LoginAttemptStore fallback;
27
    private final Clock clock;
28
    private final int maxFailures;
29
    private final long windowMillis;
30
    private final long lockMillis;
31
    private volatile Instant lastWarning = Instant.MIN;
32
    private final AtomicLong outages = new AtomicLong();
33
    private final AtomicLong warnings = new AtomicLong();
34
35
    RedisLoginAttemptStore(RedisOps redis, LoginAttemptStore fallback, Clock clock,
36
                           int maxFailures, Duration window, Duration lock) {
37
        this.redis = redis;
38
        this.fallback = fallback;
39
        this.clock = clock;
40
        this.maxFailures = maxFailures;
41
        this.windowMillis = window.toMillis();
42
        this.lockMillis = lock.toMillis();
43
    }
44
45
    static String failKey(String email) {
46 1 1. failKey : replaced return value with "" for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::failKey → KILLED
        return PREFIX + "fail:" + Hashes.sha256Hex(email);
47
    }
48
49
    static String lockKey(String email) {
50 1 1. lockKey : replaced return value with "" for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::lockKey → KILLED
        return PREFIX + "lock:" + Hashes.sha256Hex(email);
51
    }
52
53
    @Override
54
    public long lockRemainingMillis(String email) {
55
        try {
56 1 1. lockRemainingMillis : replaced long return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::lockRemainingMillis → KILLED
            return Math.max(0, redis.ttlMillis(lockKey(email)));      // -2 = no lock key
57
58
        } catch (RuntimeException e) {
59 1 1. lockRemainingMillis : removed call to com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::unavailable → KILLED
            unavailable(e);
60 1 1. lockRemainingMillis : replaced long return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::lockRemainingMillis → KILLED
            return fallback.lockRemainingMillis(email);
61
        }
62
    }
63
64
    @Override
65
    public void recordFailure(String email) {
66
        try {
67
            redis.recordFailure(failKey(email), lockKey(email), windowMillis, maxFailures, lockMillis);
68
        } catch (RuntimeException e) {
69 1 1. recordFailure : removed call to com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::unavailable → KILLED
            unavailable(e);
70 1 1. recordFailure : removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptStore::recordFailure → KILLED
            fallback.recordFailure(email);
71
        }
72
    }
73
74
    @Override
75
    public void reset(String email) {
76 1 1. reset : removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptStore::reset → KILLED
        fallback.reset(email);
77
        try {
78 1 1. reset : removed call to com/rbctcsworld/ecommerce/auth/RedisOps::delete → KILLED
            redis.delete(failKey(email));
79
        } catch (RuntimeException e) {
80 1 1. reset : removed call to com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::unavailable → KILLED
            unavailable(e);
81
        }
82
    }
83
84
    @Override
85
    public int failures(String email) {
86
        try {
87 1 1. failures : replaced int return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::failures → KILLED
            return (int) redis.count(failKey(email));
88
        } catch (RuntimeException e) {
89 1 1. failures : removed call to com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::unavailable → KILLED
            unavailable(e);
90 1 1. failures : replaced int return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::failures → KILLED
            return fallback.failures(email);
91
        }
92
    }
93
94
    @Override
95
    public void close() {
96 1 1. close : removed call to com/rbctcsworld/ecommerce/auth/RedisOps::close → KILLED
        redis.close();
97
    }
98
99
    /** Calls answered by the in-memory fallback because Redis failed. */
100
    long outages() {
101 1 1. outages : replaced long return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::outages → KILLED
        return outages.get();
102
    }
103
104
    /** Warnings written to the log (at most one per minute). */
105
    long warnings() {
106 1 1. warnings : replaced long return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::warnings → KILLED
        return warnings.get();
107
    }
108
109
    private void unavailable(RuntimeException e) {
110
        outages.incrementAndGet();
111
        Instant now = clock.instant();
112 1 1. unavailable : removed conditional - replaced equality check with false → KILLED
        if (now.isAfter(lastWarning.plusSeconds(60))) {
113
            lastWarning = now;
114
            warnings.incrementAndGet();
115
            log.warn("Redis is not reachable, login lockout uses the in-memory fallback: {}", e.toString());
116
        }
117
    }
118
}

Mutations

46

1.1
Location : failKey
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:failuresAreReadFromRedis()]
replaced return value with "" for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::failKey → KILLED

50

1.1
Location : lockKey
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:lockedWhileTheLockKeyLives()]
replaced return value with "" for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::lockKey → KILLED

56

1.1
Location : lockRemainingMillis
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:lockedWhileTheLockKeyLives()]
replaced long return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::lockRemainingMillis → KILLED

59

1.1
Location : lockRemainingMillis
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:anOutageIsLoggedOncePerMinuteNotOncePerLogin()]
removed call to com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::unavailable → KILLED

60

1.1
Location : lockRemainingMillis
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:redisDownLockCheckUsesTheFallback()]
replaced long return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::lockRemainingMillis → KILLED

69

1.1
Location : recordFailure
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:redisDownFailureIsCountedInTheFallback()]
removed call to com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::unavailable → KILLED

70

1.1
Location : recordFailure
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:redisDownFailureIsCountedInTheFallback()]
removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptStore::recordFailure → KILLED

76

1.1
Location : reset
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:successClearsTheCounterInRedisAndInTheFallback()]
removed call to com/rbctcsworld/ecommerce/auth/LoginAttemptStore::reset → KILLED

78

1.1
Location : reset
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:successClearsTheCounterInRedisAndInTheFallback()]
removed call to com/rbctcsworld/ecommerce/auth/RedisOps::delete → KILLED

80

1.1
Location : reset
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:redisDownResetStillClearsTheFallback()]
removed call to com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::unavailable → KILLED

87

1.1
Location : failures
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:failuresAreReadFromRedis()]
replaced int return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::failures → KILLED

89

1.1
Location : failures
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:redisDownFailuresComeFromTheFallback()]
removed call to com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::unavailable → KILLED

90

1.1
Location : failures
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:redisDownFailuresComeFromTheFallback()]
replaced int return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::failures → KILLED

96

1.1
Location : close
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:closeClosesTheRedisConnection()]
removed call to com/rbctcsworld/ecommerce/auth/RedisOps::close → KILLED

101

1.1
Location : outages
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:anOutageIsLoggedOncePerMinuteNotOncePerLogin()]
replaced long return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::outages → KILLED

106

1.1
Location : warnings
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:anOutageIsLoggedOncePerMinuteNotOncePerLogin()]
replaced long return with 0 for com/rbctcsworld/ecommerce/auth/RedisLoginAttemptStore::warnings → KILLED

112

1.1
Location : unavailable
Killed by : com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RedisLoginAttemptStoreTest]/[method:anOutageIsLoggedOncePerMinuteNotOncePerLogin()]
removed conditional - replaced equality check with false → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.0 support