diff --git a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java index e9d7822..4ca1996 100644 --- a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java +++ b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java @@ -1,13 +1,17 @@ /* © SRSoftware 2024 */ package de.srsoftware.oidc.api; +import static java.util.Optional.empty; + import de.srsoftware.oidc.api.data.AccessToken; +import de.srsoftware.oidc.api.data.FailedLogin; import de.srsoftware.oidc.api.data.User; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import java.time.Instant; +import java.util.*; public interface UserService { + Map failedLogins = new HashMap<>(); + /** * create a new access token for a given user * @param user @@ -22,13 +26,30 @@ public interface UserService { * @param accessToken * @return */ - public Optional forToken(String accessToken); - public UserService init(User defaultUser); - public List list(); - public Set find(String idOrEmail); - public Optional load(String id); - public Optional load(String username, String password); - public boolean passwordMatches(String plaintextPassword, User user); - public UserService save(User user); - public UserService updatePassword(User user, String plaintextPassword); + public Optional forToken(String accessToken); + public UserService init(User defaultUser); + public List list(); + public Set find(String idOrEmail); + public default Optional getLock(String id) { + var failedLogin = failedLogins.get(id); + if (failedLogin == null || failedLogin.releaseTime().isBefore(Instant.now())) return empty(); + return Optional.of(failedLogin); + } + public Optional load(String id); + public Optional login(String username, String password); + public default UserService lock(String id) { + var failedLogin = failedLogins.get(id); + if (failedLogin == null) { + failedLogins.put(id, failedLogin = new FailedLogin(id)); + } + + return this; + } + public boolean passwordMatches(String plaintextPassword, User user); + public UserService save(User user); + public default UserService unlock(String id) { + failedLogins.remove(id); + return this; + } + public UserService updatePassword(User user, String plaintextPassword); } diff --git a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/data/FailedLogin.java b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/data/FailedLogin.java new file mode 100644 index 0000000..1550cdd --- /dev/null +++ b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/data/FailedLogin.java @@ -0,0 +1,32 @@ +/* © SRSoftware 2024 */ +package de.srsoftware.oidc.api.data; + +import java.time.Instant; + +public class FailedLogin { + private final String userId; + private int attempts; + private Instant releaseTime; + + public FailedLogin(String userId) { + this.userId = userId; + this.attempts = 0; + count(); + } + + public void count() { + attempts++; + if (attempts > 13) attempts = 13; + var seconds = 1; + for (long i = 0; i < attempts; i++) seconds *= 2; + releaseTime = Instant.now().plusSeconds(seconds); + } + + public int attempts() { + return attempts; + } + + public Instant releaseTime() { + return releaseTime; + } +} diff --git a/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java b/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java index 84ac47e..8e8e536 100644 --- a/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java +++ b/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java @@ -74,7 +74,7 @@ public class Application { HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); var staticPages = (StaticPages) new StaticPages(basePath).bindPath(STATIC_PATH, FAVICON).on(server); new Forward(INDEX).bindPath(ROOT).on(server); - new WellKnownController().bindPath(WELL_KNOWN).on(server); + new WellKnownController().bindPath(WELL_KNOWN, "/realms/oidc" + WELL_KNOWN).on(server); new UserController(mailConfig, sessionService, userService, staticPages).bindPath(API_USER).on(server); var tokenControllerConfig = new TokenController.Configuration("https://lightoidc.srsoftware.de", 10); // TODO configure or derive from hostname new TokenController(authService, clientService, keyManager, userService, tokenControllerConfig).bindPath(API_TOKEN).on(server); diff --git a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java index ae0417b..74f5594 100644 --- a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java +++ b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java @@ -195,7 +195,7 @@ public class UserController extends Controller { var password = body.has(PASSWORD) ? body.getString(PASSWORD) : null; var trust = body.has(TRUST) ? body.getBoolean(TRUST) : false; - Optional user = users.load(username, password); + Optional user = users.login(username, password); if (user.isPresent()) return sendUserAndCookie(ex, sessions.createSession(user.get(), trust), user.get()); return sendEmptyResponse(HTTP_UNAUTHORIZED, ex); } diff --git a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/WellKnownController.java b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/WellKnownController.java index ce90443..2a0b860 100644 --- a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/WellKnownController.java +++ b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/WellKnownController.java @@ -5,6 +5,7 @@ package de.srsoftware.oidc.backend; import com.sun.net.httpserver.HttpExchange; import de.srsoftware.http.PathHandler; import java.io.IOException; +import java.util.List; import java.util.Map; public class WellKnownController extends PathHandler { @@ -19,6 +20,12 @@ public class WellKnownController extends PathHandler { private boolean openidConfig(HttpExchange ex) throws IOException { var host = hostname(ex); - return sendContent(ex, Map.of("token_endpoint", host + "/api/token", "authorization_endpoint", host + "/web/authorization.html", "userinfo_endpoint", host + "/api/user/info", "jwks_uri", host + "/api/jwks.json", "issuer", "https://lightoidc.srsoftware.de")); + return sendContent(ex, Map.of("token_endpoint", host + "/api/token", // + "authorization_endpoint", host + "/web/authorization.html", // + "userinfo_endpoint", host + "/api/user/info", // + "jwks_uri", host + "/api/jwks.json", // + "issuer", "https://lightoidc.srsoftware.de", // + "id_token_signing_alg_values_supported", List.of("RS256"), // + "subject_types_supported", List.of("public", "pairwise"))); } } diff --git a/de.srsoftware.oidc.datastore.encrypted/src/main/java/de/srsoftware/oidc/datastore/encrypted/EncryptedUserService.java b/de.srsoftware.oidc.datastore.encrypted/src/main/java/de/srsoftware/oidc/datastore/encrypted/EncryptedUserService.java index bdcd9b8..8a0af78 100644 --- a/de.srsoftware.oidc.datastore.encrypted/src/main/java/de/srsoftware/oidc/datastore/encrypted/EncryptedUserService.java +++ b/de.srsoftware.oidc.datastore.encrypted/src/main/java/de/srsoftware/oidc/datastore/encrypted/EncryptedUserService.java @@ -1,20 +1,19 @@ /* © SRSoftware 2024 */ package de.srsoftware.oidc.datastore.encrypted; +import static java.lang.System.Logger.Level.WARNING; import static java.util.Optional.empty; import de.srsoftware.oidc.api.UserService; import de.srsoftware.oidc.api.data.AccessToken; import de.srsoftware.oidc.api.data.User; import de.srsoftware.utils.PasswordHasher; -import java.util.HashMap; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import java.util.*; public class EncryptedUserService extends EncryptedConfig implements UserService { - private final UserService backend; - private final PasswordHasher hasher; + private static final System.Logger LOG = System.getLogger(EncryptedUserService.class.getSimpleName()); + private final UserService backend; + private final PasswordHasher hasher; public EncryptedUserService(UserService backend, String key, String salt, PasswordHasher passHasher) { super(key, salt); @@ -94,12 +93,23 @@ public class EncryptedUserService extends EncryptedConfig implements UserService } @Override - public Optional load(String username, String password) { + public Optional login(String username, String password) { if (username == null || username.isBlank()) return empty(); + var optLock = getLock(username); + if (optLock.isPresent()) { + var lock = optLock.get(); + LOG.log(WARNING, "{} is locked after {} failed logins. Lock will be released at {}", username, lock.attempts(), lock.releaseTime()); + return empty(); + } for (var encryptedUser : backend.list()) { var decryptedUser = decrypt(encryptedUser); - if (username.equals(decryptedUser.username()) && hasher.matches(password, decryptedUser.hashedPassword())) return Optional.of(decryptedUser); + if (!username.equals(decryptedUser.username())) continue; + if (hasher.matches(password, decryptedUser.hashedPassword())) { + this.unlock(username); + return Optional.of(decryptedUser); + } } + lock(username); return empty(); } diff --git a/de.srsoftware.oidc.datastore.encrypted/src/test/java/EncryptedUserServiceTest.java b/de.srsoftware.oidc.datastore.encrypted/src/test/java/EncryptedUserServiceTest.java index 01fdd02..c3e33bf 100644 --- a/de.srsoftware.oidc.datastore.encrypted/src/test/java/EncryptedUserServiceTest.java +++ b/de.srsoftware.oidc.datastore.encrypted/src/test/java/EncryptedUserServiceTest.java @@ -1,6 +1,7 @@ /* © SRSoftware 2024 */ import static de.srsoftware.utils.Optionals.nullable; import static de.srsoftware.utils.Strings.uuid; +import static java.lang.System.Logger.Level.WARNING; import de.srsoftware.oidc.api.UserService; import de.srsoftware.oidc.api.UserServiceTest; @@ -15,6 +16,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; public class EncryptedUserServiceTest extends UserServiceTest { + private static final System.Logger LOG = System.getLogger(EncryptedUserServiceTest.class.getSimpleName()); private class InMemoryUserService implements UserService { private final PasswordHasher hasher; private HashMap users = new HashMap<>(); @@ -66,8 +68,23 @@ public class EncryptedUserServiceTest extends UserServiceTest { } @Override - public Optional load(String username, String password) { - return users.values().stream().filter(user -> user.username().equals(username) && passwordMatches(password, user)).findAny(); + public Optional login(String username, String password) { + var optLock = getLock(username); + if (optLock.isPresent()) { + var lock = optLock.get(); + LOG.log(WARNING, "{} is locked after {} failed logins. Lock will be released at {}", username, lock.attempts(), lock.releaseTime()); + return Optional.empty(); + } + + for (var entry : users.entrySet()) { + var user = entry.getValue(); + if (user.username().equals(username) && passwordMatches(password, user)) { + unlock(username); + return Optional.of(user); + } + } + lock(username); + return Optional.empty(); } @Override diff --git a/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java b/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java index 56d388a..559104b 100644 --- a/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java +++ b/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java @@ -140,6 +140,7 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe return this; } + @Override public Set find(String key) { if (!json.has(USERS)) return Set.of(); @@ -175,8 +176,14 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe } @Override - public Optional load(String user, String password) { + public Optional login(String user, String password) { if (!json.has(USERS)) return empty(); + var optLock = getLock(user); + if (optLock.isPresent()) { + var lock = optLock.get(); + LOG.log(WARNING, "{} is locked after {} failed logins. Lock will be released at {}", user, lock.attempts(), lock.releaseTime()); + return empty(); + } try { var users = json.getJSONObject(USERS); for (String userId : users.keySet()) { @@ -184,8 +191,13 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe if (KEYS.stream().map(userData::getString).noneMatch(val -> val.equals(user))) continue; var loadedUser = User.of(userData, userId).filter(u -> passwordMatches(password, u)); - if (loadedUser.isPresent()) return loadedUser; + if (loadedUser.isPresent()) { + unlock(user); + return loadedUser; + } + lock(userId); } + lock(user); return empty(); } catch (Exception e) { return empty(); @@ -210,6 +222,7 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe return save(); } + @Override public FileStore updatePassword(User user, String plaintextPassword) { return save(user.hashedPassword(passwordHasher.hash(plaintextPassword, uuid())));