diff --git a/de.srsoftware.http/src/main/java/de/srsoftware/http/PathHandler.java b/de.srsoftware.http/src/main/java/de/srsoftware/http/PathHandler.java index 796fc65..11b67f8 100644 --- a/de.srsoftware.http/src/main/java/de/srsoftware/http/PathHandler.java +++ b/de.srsoftware.http/src/main/java/de/srsoftware/http/PathHandler.java @@ -11,6 +11,7 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpsExchange; +import de.srsoftware.utils.Error; import java.io.IOException; import java.util.*; import java.util.stream.Collectors; @@ -176,7 +177,7 @@ public abstract class PathHandler implements HttpHandler { public static boolean sendContent(HttpExchange ex, int status, Object o) throws IOException { if (o instanceof List list) o = new JSONArray(list); if (o instanceof Map map) o = new JSONObject(map); - if (o instanceof JSONObject) ex.getResponseHeaders().add(CONTENT_TYPE, JSON); + if (o instanceof Error error) o = error.json(); return sendContent(ex, status, o.toString().getBytes(UTF_8)); } diff --git a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Constants.java b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Constants.java index 5c22717..8d46db7 100644 --- a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Constants.java +++ b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/Constants.java @@ -6,6 +6,7 @@ public class Constants { public static final String ACCESS_TOKEN = "access_token"; public static final String APP_NAME = "LightOIDC"; public static final String AT_HASH = "at_hash"; + public static final String ATTEMPTS = "attempts"; public static final String AUTH_CODE = "authorization_code"; public static final String AUTHORZED = "authorized"; public static final String BEARER = "Bearer"; @@ -20,6 +21,9 @@ public class Constants { public static final String DAYS = "days"; public static final String ENCRYPTION_KEY = "encryption_key"; public static final String ERROR_DESCRIPTION = "error_description"; + public static final String ERROR_LOCKED = "error_locked"; + public static final String ERROR_LOGIN_FAILED = "error_login_failed"; + public static final String ERROR_NO_USERNAME = "error_no_username"; public static final String EXPIRATION = "expiration"; public static final String EXPIRES_IN = "expires_in"; public static final String GRANT_TYPE = "grant_type"; @@ -38,6 +42,7 @@ public class Constants { public static final String OPENID = "openid"; public static final String REDIRECT_URI = "redirect_uri"; public static final String REDIRECT_URIS = "redirect_uris"; + public static final String RELEASE = "release"; public static final String REQUEST_NOT_SUPPORTED = "request_not_supported"; public static final String RESPONSE_TYPE = "response_type"; public static final String SALT = "salt"; 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..0ad91a1 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,18 @@ /* © 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.Lock; import de.srsoftware.oidc.api.data.User; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import de.srsoftware.utils.Result; +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 +27,25 @@ 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 forToken(String accessToken); + public UserService init(User defaultUser); + public List list(); + public Set find(String idOrEmail); + public default Optional getLock(String key) { + var failedLogin = failedLogins.get(key); + if (failedLogin == null || failedLogin.releaseTime().isBefore(Instant.now())) return empty(); + return Optional.of(failedLogin); + } 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 Result login(String username, String password); + public default Lock lock(String key) { + return failedLogins.computeIfAbsent(key, k -> new Lock()).count(); + } + public boolean passwordMatches(String plaintextPassword, User user); + public UserService save(User user); + public default UserService unlock(String key) { + failedLogins.remove(key); + return this; + } + public UserService updatePassword(User user, String plaintextPassword); } diff --git a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/data/Lock.java b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/data/Lock.java new file mode 100644 index 0000000..743bf49 --- /dev/null +++ b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/data/Lock.java @@ -0,0 +1,31 @@ +/* © SRSoftware 2024 */ +package de.srsoftware.oidc.api.data; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +public class Lock { + private int attempts; + private Instant releaseTime; + + public Lock() { + this.attempts = 0; + } + + public Lock count() { + attempts++; + if (attempts > 13) attempts = 13; + var seconds = 5; + for (long i = 0; i < attempts; i++) seconds *= 2; + releaseTime = Instant.now().plusSeconds(seconds).truncatedTo(ChronoUnit.SECONDS); + return this; + } + + 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 738608f..a7bc4c5 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 @@ -77,7 +77,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..52e945a 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 @@ -15,6 +15,8 @@ import de.srsoftware.oidc.api.*; import de.srsoftware.oidc.api.data.Permission; import de.srsoftware.oidc.api.data.Session; import de.srsoftware.oidc.api.data.User; +import de.srsoftware.utils.Payload; +import de.srsoftware.utils.Result; import jakarta.mail.*; import jakarta.mail.internet.*; import java.io.IOException; @@ -193,11 +195,11 @@ public class UserController extends Controller { var username = body.has(USERNAME) ? body.getString(USERNAME) : null; var password = body.has(PASSWORD) ? body.getString(PASSWORD) : null; - var trust = body.has(TRUST) ? body.getBoolean(TRUST) : false; + var trust = body.has(TRUST) && body.getBoolean(TRUST); - Optional user = users.load(username, password); - if (user.isPresent()) return sendUserAndCookie(ex, sessions.createSession(user.get(), trust), user.get()); - return sendEmptyResponse(HTTP_UNAUTHORIZED, ex); + Result result = users.login(username, password); + if (result instanceof Payload user) return sendUserAndCookie(ex, sessions.createSession(user.get(), trust), user.get()); + return sendContent(ex, HTTP_UNAUTHORIZED, result); } private boolean logout(HttpExchange ex, Session session) throws IOException { 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..67c51e1 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,23 @@ /* © SRSoftware 2024 */ package de.srsoftware.oidc.datastore.encrypted; +import static de.srsoftware.oidc.api.Constants.*; +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.Error; import de.srsoftware.utils.PasswordHasher; -import java.util.HashMap; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import de.srsoftware.utils.Payload; +import de.srsoftware.utils.Result; +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,13 +97,26 @@ public class EncryptedUserService extends EncryptedConfig implements UserService } @Override - public Optional load(String username, String password) { - if (username == null || username.isBlank()) return empty(); + public Result login(String username, String password) { + if (username == null || username.isBlank()) return Error.message(ERROR_NO_USERNAME); + var optLock = getLock(username); + if (optLock.isPresent()) { + var lock = optLock.get(); + LOG.log(WARNING, "{0} is locked after {1} failed logins. Lock will be released at {2}", username, lock.attempts(), lock.releaseTime()); + return Error.message(ERROR_LOCKED, ATTEMPTS, lock.attempts(), RELEASE, lock.releaseTime()); + } 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 Payload.of(decryptedUser); + } } - return empty(); + + var lock = lock(username); + LOG.log(WARNING, "Login failed for {0} → locking account until {1}", username, lock.releaseTime()); + return Error.message(ERROR_LOGIN_FAILED, RELEASE, lock.releaseTime()); } @Override 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..0dcfeb8 100644 --- a/de.srsoftware.oidc.datastore.encrypted/src/test/java/EncryptedUserServiceTest.java +++ b/de.srsoftware.oidc.datastore.encrypted/src/test/java/EncryptedUserServiceTest.java @@ -1,13 +1,17 @@ /* © SRSoftware 2024 */ +import static de.srsoftware.oidc.api.Constants.*; 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; +import de.srsoftware.oidc.api.*; import de.srsoftware.oidc.api.data.AccessToken; import de.srsoftware.oidc.api.data.User; import de.srsoftware.oidc.datastore.encrypted.EncryptedUserService; +import de.srsoftware.utils.Error; import de.srsoftware.utils.PasswordHasher; +import de.srsoftware.utils.Payload; +import de.srsoftware.utils.Result; import java.io.File; import java.util.*; import java.util.stream.Collectors; @@ -15,6 +19,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 +71,24 @@ 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 Result 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 Error.message(ERROR_LOCKED, ATTEMPTS, lock.attempts(), RELEASE, lock.releaseTime()); + } + + for (var entry : users.entrySet()) { + var user = entry.getValue(); + if (user.username().equals(username) && passwordMatches(password, user)) { + unlock(username); + return Payload.of(user); + } + } + var lock = lock(username); + LOG.log(WARNING, "Login failed for {0} → locking account until {1}", username, lock.releaseTime()); + return Error.message(ERROR_LOGIN_FAILED, RELEASE, lock.releaseTime()); } @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..8e2ba9e 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 @@ -10,7 +10,10 @@ import static java.util.Optional.empty; import de.srsoftware.oidc.api.*; import de.srsoftware.oidc.api.data.*; +import de.srsoftware.utils.Error; import de.srsoftware.utils.PasswordHasher; +import de.srsoftware.utils.Payload; +import de.srsoftware.utils.Result; import jakarta.mail.Authenticator; import jakarta.mail.PasswordAuthentication; import java.io.File; @@ -140,6 +143,7 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe return this; } + @Override public Set find(String key) { if (!json.has(USERS)) return Set.of(); @@ -175,20 +179,32 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe } @Override - public Optional load(String user, String password) { - if (!json.has(USERS)) return empty(); + public Result login(String username, String password) { + if (!json.has(USERS)) return Error.message(ERROR_LOGIN_FAILED); + if (username == null || username.isBlank()) return Error.message(ERROR_NO_USERNAME); + var optLock = getLock(username); + if (optLock.isPresent()) { + var lock = optLock.get(); + LOG.log(WARNING, "{0} is locked after {1} failed logins. Lock will be released at {2}", username, lock.attempts(), lock.releaseTime()); + return Error.message(ERROR_LOCKED, ATTEMPTS, lock.attempts(), RELEASE, lock.releaseTime()); + } try { var users = json.getJSONObject(USERS); for (String userId : users.keySet()) { var userData = users.getJSONObject(userId); - if (KEYS.stream().map(userData::getString).noneMatch(val -> val.equals(user))) continue; + if (KEYS.stream().map(userData::getString).noneMatch(val -> val.equals(username))) continue; var loadedUser = User.of(userData, userId).filter(u -> passwordMatches(password, u)); - if (loadedUser.isPresent()) return loadedUser; + if (loadedUser.isPresent()) { + unlock(username); + return Payload.of(loadedUser.get()); + } } - return empty(); + var lock = lock(username); + LOG.log(WARNING, "Login failed for {0} → locking account until {1}", username, lock.releaseTime()); + return Error.message(ERROR_LOGIN_FAILED, RELEASE, lock.releaseTime()); } catch (Exception e) { - return empty(); + return Error.message(ERROR_LOGIN_FAILED); } } @@ -210,6 +226,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()))); diff --git a/de.srsoftware.oidc.datastore.sqlite/src/main/java/de/srsoftware/oidc/datastore/sqlite/SqliteUserService.java b/de.srsoftware.oidc.datastore.sqlite/src/main/java/de/srsoftware/oidc/datastore/sqlite/SqliteUserService.java index 33f88b9..cd2ea34 100644 --- a/de.srsoftware.oidc.datastore.sqlite/src/main/java/de/srsoftware/oidc/datastore/sqlite/SqliteUserService.java +++ b/de.srsoftware.oidc.datastore.sqlite/src/main/java/de/srsoftware/oidc/datastore/sqlite/SqliteUserService.java @@ -1,15 +1,20 @@ /* © SRSoftware 2024 */ package de.srsoftware.oidc.datastore.sqlite; +import static de.srsoftware.oidc.api.Constants.*; import static de.srsoftware.utils.Optionals.nullable; import static de.srsoftware.utils.Strings.uuid; +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.Permission; import de.srsoftware.oidc.api.data.User; +import de.srsoftware.utils.Error; import de.srsoftware.utils.PasswordHasher; +import de.srsoftware.utils.Payload; +import de.srsoftware.utils.Result; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; @@ -209,12 +214,23 @@ public class SqliteUserService extends SqliteStore implements UserService { } @Override - public Optional load(String username, String password) { - var candidates = find(username); - for (var user : candidates) { - if (passwordMatches(password, user)) return Optional.of(user); + public Result login(String username, String password) { + if (username == null || username.isBlank()) return Error.message(ERROR_NO_USERNAME); + var optLock = getLock(username); + if (optLock.isPresent()) { + var lock = optLock.get(); + LOG.log(WARNING, "{0} is locked after {1} failed logins. Lock will be released at {2}", username, lock.attempts(), lock.releaseTime()); + return Error.message(ERROR_LOCKED, ATTEMPTS, lock.attempts(), RELEASE, lock.releaseTime()); } - return empty(); + for (var user : find(username)) { + if (passwordMatches(password, user)) { + this.unlock(username); + return Payload.of(user); + } + } + var lock = lock(username); + LOG.log(WARNING, "Login failed for {0} → locking account until {1}", username, lock.releaseTime()); + return Error.message(ERROR_LOGIN_FAILED, RELEASE, lock.releaseTime()); } @Override diff --git a/de.srsoftware.oidc.web/src/main/resources/de/login.html b/de.srsoftware.oidc.web/src/main/resources/de/login.html index a920ace..a09defc 100644 --- a/de.srsoftware.oidc.web/src/main/resources/de/login.html +++ b/de.srsoftware.oidc.web/src/main/resources/de/login.html @@ -24,10 +24,16 @@ Passwort - + Fehler Anmeldung fehlgeschlagen! + + Fehler + + Account gesperrt bis + +