From 5100ac244ad7517c634567b8c73639cf68735cb3 Mon Sep 17 00:00:00 2001 From: Stephan Richter Date: Wed, 2 Jul 2025 23:20:21 +0200 Subject: [PATCH] implemented user impersonization --- .../umbrella/backend/Application.java | 9 +- build.gradle.kts | 2 +- frontend/src/routes/user/List.svelte | 19 ++- frontend/src/routes/user/LoginServices.svelte | 45 +++++ frontend/src/routes/user/User.svelte | 2 + .../{Components => unused}/ClickInput.svelte | 0 .../{Components => unused}/ClickSelect.svelte | 0 .../EditableField.svelte | 0 translations/src/main/resources/de.json | 5 + .../de/srsoftware/umbrella/user/Paths.java | 3 +- .../srsoftware/umbrella/user/UserModule.java | 155 ++++++++++++------ web/src/main/resources/web/css/default.css | 9 + 12 files changed, 192 insertions(+), 57 deletions(-) create mode 100644 frontend/src/routes/user/LoginServices.svelte rename frontend/src/{Components => unused}/ClickInput.svelte (100%) rename frontend/src/{Components => unused}/ClickSelect.svelte (100%) rename frontend/src/{Components => unused}/EditableField.svelte (100%) diff --git a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java index 92c0263..e18d66a 100644 --- a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java +++ b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java @@ -16,8 +16,10 @@ import java.net.InetSocketAddress; import java.util.concurrent.Executors; public class Application { - private static final System.Logger LOG = System.getLogger("Umbrella"); - private static final String USER_DB = "/home/srichter/workspace/umbrella/data/umbrella.db"; + private static final System.Logger LOG = System.getLogger("Umbrella"); + private static final String USER_DB = "/home/srichter/workspace/umbrella/data/umbrella.db"; + private static final String LOGIN_SERVICE_DB = "/home/srichter/workspace/umbrella/data/umbrella.db"; + public static void main(String[] args) throws IOException { ColorLogger.setRootLogLevel(DEBUG); LOG.log(INFO, "Starting Umbrella:"); @@ -25,10 +27,11 @@ public class Application { var threads = 16; var connectionProvider = new ConnectionProvider(); var userDb = new SqliteDB(connectionProvider.get(USER_DB)); + var loginServicedb = new SqliteDB(connectionProvider.get(LOGIN_SERVICE_DB)); HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); server.setExecutor(Executors.newFixedThreadPool(threads)); new WebHandler().bindPath("/").on(server); - new UserModule(userDb).bindPath("/api/user").on(server); + new UserModule(userDb,loginServicedb).bindPath("/api/user").on(server); new Translations().bindPath("/api/translations").on(server); LOG.log(INFO,"Started web server at {0}",port); server.start(); diff --git a/build.gradle.kts b/build.gradle.kts index c59b90d..7ea4cc1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,7 +40,7 @@ subprojects { dependencies { testImplementation(platform("org.junit:junit-bom:5.10.0")) testImplementation("org.junit.jupiter:junit-jupiter") - implementation("de.srsoftware:tools.http:6.0.3") + implementation("de.srsoftware:tools.http:6.0.4") implementation("de.srsoftware:tools.logging:1.3.2") } diff --git a/frontend/src/routes/user/List.svelte b/frontend/src/routes/user/List.svelte index 7ddc99d..1d4a778 100644 --- a/frontend/src/routes/user/List.svelte +++ b/frontend/src/routes/user/List.svelte @@ -13,12 +13,24 @@ const resp = await fetch(url,{credentials:'include'}); if (resp.ok){ const json = await resp.json(); - for (let user of json) users.push(user); + for (let u of json) users.push(u); } }); + + async function impersonate(userId){ + const url = `${location.protocol}//${location.host.replace('5173','8080')}/api/user/${userId}/impersonate`; + const resp = await fetch(url,{ + method: 'POST', + credentials: 'include' + }); + if (resp.ok){ + const json = await resp.json(); + for (let key of Object.keys(json)) user[key] = json[key]; + } + } -
+
{t('user.list')} @@ -38,6 +50,9 @@
{u.email} {u.language} + {#if user.permissions.includes('IMPERSONATE')} + + {/if} {#if user.permissions.includes('UPDATE_USERS')} {/if} diff --git a/frontend/src/routes/user/LoginServices.svelte b/frontend/src/routes/user/LoginServices.svelte new file mode 100644 index 0000000..1bdb397 --- /dev/null +++ b/frontend/src/routes/user/LoginServices.svelte @@ -0,0 +1,45 @@ + + +
+ {t('user.login_services')} + + + + + + + + + {#each services as service,i} + + + + + {/each} + +
{t('user.service')}{t('user.actions')}
{service} + + {#if user.permissions.includes('MANAGE_LOGIN_SERVICES')} + + + {/if} +
+
\ No newline at end of file diff --git a/frontend/src/routes/user/User.svelte b/frontend/src/routes/user/User.svelte index 9d0e3a7..9d7a629 100644 --- a/frontend/src/routes/user/User.svelte +++ b/frontend/src/routes/user/User.svelte @@ -5,6 +5,7 @@ import EditPassword from './EditPassword.svelte'; import UserList from './List.svelte'; + import LoginServiceList from './LoginServices.svelte'; const router = useTinyRouter(); @@ -90,4 +91,5 @@ {#if user.permissions.includes('LIST_USERS')} {/if} + diff --git a/frontend/src/Components/ClickInput.svelte b/frontend/src/unused/ClickInput.svelte similarity index 100% rename from frontend/src/Components/ClickInput.svelte rename to frontend/src/unused/ClickInput.svelte diff --git a/frontend/src/Components/ClickSelect.svelte b/frontend/src/unused/ClickSelect.svelte similarity index 100% rename from frontend/src/Components/ClickSelect.svelte rename to frontend/src/unused/ClickSelect.svelte diff --git a/frontend/src/Components/EditableField.svelte b/frontend/src/unused/EditableField.svelte similarity index 100% rename from frontend/src/Components/EditableField.svelte rename to frontend/src/unused/EditableField.svelte diff --git a/translations/src/main/resources/de.json b/translations/src/main/resources/de.json index 3098336..71a13e2 100644 --- a/translations/src/main/resources/de.json +++ b/translations/src/main/resources/de.json @@ -20,8 +20,11 @@ "user" : { "actions": "Aktionen", "abort": "abbrechen", + "add_login_service": "Login-Service anlegen", + "connect_service": "mit Service verbinden", "CREATE_USERS": "NUTZER ANLEGEN", "data_sent": "Daten übermittelt", + "delete": "löschen", "DELETE_USERS": "NUTZER LÖSCHEN", "edit": "Bearbeiten", "editing": "Nutzer {0} bearbeiten", @@ -29,6 +32,7 @@ "email": "E-Mail", "failed": "fehlgeschlagen", "id": "Id", + "impersonate": "zu Nutzer wechseln", "IMPERSONATE": "NUTZER WECHSELN", "language": "Sprache", "list": "Benutzer-Liste", @@ -46,6 +50,7 @@ "repeat_new_password": "Wiederholung", "saved": "gespeichert", "save_user": "Nutzer speichern", + "service": "Service", "theme": "Design", "your_profile": "dein Profil", "update": "aktualisieren", diff --git a/user/src/main/java/de/srsoftware/umbrella/user/Paths.java b/user/src/main/java/de/srsoftware/umbrella/user/Paths.java index f480862..8821a53 100644 --- a/user/src/main/java/de/srsoftware/umbrella/user/Paths.java +++ b/user/src/main/java/de/srsoftware/umbrella/user/Paths.java @@ -9,10 +9,9 @@ public class Paths { public static final String IMPERSONATE = "impersonate"; public static final String INSTALL = "install"; public static final String JAVASCRIPT = "js"; - public static final String LOGIN = "login"; public static final String MENU = "menu"; public static final String NOTIFY = "notify"; - public static final String OIDC_BUTTONS = "oidc_buttons"; + public static final String BUTTONS = "buttons"; public static final String OIDC_LOGIN = "oidc_login"; public static final String OPENID_LOGIN = "openid_login"; public static final String SESSION = "session"; diff --git a/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java b/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java index 091f0b0..d5a2dac 100644 --- a/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java +++ b/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java @@ -6,11 +6,12 @@ import static de.srsoftware.umbrella.core.Constants.*; import static de.srsoftware.umbrella.core.Paths.LIST; import static de.srsoftware.umbrella.core.Paths.LOGOUT; import static de.srsoftware.umbrella.core.ResponseCode.*; +import static de.srsoftware.umbrella.core.ResponseCode.HTTP_SERVER_ERROR; import static de.srsoftware.umbrella.user.Constants.*; -import static de.srsoftware.umbrella.user.Paths.LOGIN; -import static de.srsoftware.umbrella.user.Paths.WHOAMI; -import static de.srsoftware.umbrella.user.model.DbUser.PERMISSION.LIST_USERS; -import static de.srsoftware.umbrella.user.model.DbUser.PERMISSION.UPDATE_USERS; +import static de.srsoftware.umbrella.user.Paths.*; +import static de.srsoftware.umbrella.user.Paths.IMPERSONATE; +import static de.srsoftware.umbrella.user.model.DbUser.PERMISSION; +import static de.srsoftware.umbrella.user.model.DbUser.PERMISSION.*; import static java.lang.System.Logger.Level.WARNING; import static java.net.HttpURLConnection.*; import static java.time.temporal.ChronoUnit.DAYS; @@ -19,8 +20,8 @@ import com.sun.net.httpserver.HttpExchange; import de.srsoftware.tools.Path; import de.srsoftware.tools.PathHandler; import de.srsoftware.tools.SessionToken; -import de.srsoftware.umbrella.core.ResponseCode; import de.srsoftware.umbrella.core.UmbrellaException; +import de.srsoftware.umbrella.user.api.LoginServiceDb; import de.srsoftware.umbrella.user.api.UserDb; import de.srsoftware.umbrella.user.model.*; import java.io.IOException; @@ -35,6 +36,7 @@ public class UserModule extends PathHandler { private static final BadHasher BAD_HASHER; private static final System.Logger LOG = System.getLogger("User"); private final UserDb users; + private final LoginServiceDb logins; static { try { @@ -44,8 +46,9 @@ public class UserModule extends PathHandler { } } - public UserModule(UserDb userDb){ + public UserModule(UserDb userDb, LoginServiceDb loginDb){ users = userDb; + logins = loginDb; } private HttpExchange addCors(HttpExchange ex){ @@ -77,6 +80,7 @@ public class UserModule extends PathHandler { switch (head) { case LIST: return getUserList(ex, user); case LOGOUT: return logout(ex, sessionToken); + case SERVICE: return getService(ex,user,path); case WHOAMI: return getUser(ex, user); }; @@ -149,49 +153,52 @@ public class UserModule extends PathHandler { } } - private boolean getUserList(HttpExchange ex, UmbrellaUser user) throws IOException { - if (user instanceof DbUser dbUser && dbUser.permissions().contains(LIST_USERS)){ - try { - var list = users.list(0, null).stream().map(UmbrellaUser::toMap).toList(); - return sendContent(ex,list); - } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); - } + @Override + public boolean doPost(Path path, HttpExchange ex) throws IOException { + addCors(ex); + var head = path.pop(); + Long targetId = null; + try { + targetId = Long.parseLong(head); + head = path.pop(); + } catch (NumberFormatException ignored) {} + switch (head){ + case IMPERSONATE: return impersonate(ex,targetId); + case LOGIN: return postLogin(ex); } - return sendContent(ex,HTTP_FORBIDDEN,"You are not allowed to list users!"); + return super.doPost(path, ex); } - private boolean patchPassword(HttpExchange ex, UmbrellaUser requestingUser) throws IOException { - if (!(requestingUser instanceof DbUser user)) return sendContent(ex, ResponseCode.HTTP_SERVER_ERROR,"DbUser expected"); - JSONObject json; - try { - json = json(ex); - } catch (Exception e){ - LOG.log(WARNING,"Request does not contain valid JSON",e); - return sendContent(ex,HTTP_BAD_REQUEST,"Body contains no JSON data"); - } - if (!json.has("old") || !(json.get("old") instanceof String oldpass) || oldpass.isBlank()) return sendContent(ex, HTTP_UNPROCESSABLE,"old password missing!"); - if (!json.has("new") || !(json.get("new") instanceof String newpass) || newpass.isBlank()) return sendContent(ex, HTTP_UNPROCESSABLE,"new password missing!"); - var old = Password.of(BAD_HASHER.hash(oldpass,null)); - if (!user.hashedPassword().equals(old)) return sendContent(ex,HTTP_UNAUTHORIZED,"Wrong password (old)"); - if (weak(newpass)) return sendContent(ex,HTTP_BAD_REQUEST,"New password too weak!"); - var pass = Password.of(BAD_HASHER.hash(newpass,null)); + private boolean getService(HttpExchange ex, UmbrellaUser user, Path path) throws IOException { + var head = path.pop(); + return switch (head){ + case BUTTONS -> getOidcButtons(ex); + case LIST -> getServiceList(ex,user); + case null, default -> super.doGet(path,ex); + }; + } + + private boolean getOidcButtons(HttpExchange ex) throws IOException { try { - var updated = users.save(new DbUser(user.id(), user.name(), user.email(), pass, user.theme(), user.language(), user.permissions(), null)); - return sendContent(ex, updated); + var services = logins.listLoginServices().stream().map(LoginService::name); + return sendContent(ex,services); } catch (UmbrellaException e) { return sendContent(ex,e.statusCode(),e.getMessage()); + } catch (IOException e) { + return sendContent(ex,HTTP_SERVER_ERROR,e.getMessage()); } } - @Override - public boolean doPost(Path path, HttpExchange ex) throws IOException { - addCors(ex); - var p = path.toString(); - switch (p){ - case LOGIN: return postLogin(ex); + private boolean getServiceList(HttpExchange ex, UmbrellaUser user) throws IOException { + if (!(user instanceof DbUser dbUser && dbUser.permissions().contains(MANAGE_LOGIN_SERVICES))) return sendEmptyResponse(HTTP_FORBIDDEN,ex); + try { + var services = logins.listLoginServices().stream().map(LoginService::toMap); + return sendContent(ex,services); + } catch (UmbrellaException e) { + return sendContent(ex,e.statusCode(),e.getMessage()); + } catch (IOException e) { + return sendContent(ex,HTTP_SERVER_ERROR,e.getMessage()); } - return super.doPost(path, ex); } private boolean getUser(HttpExchange ex, UmbrellaUser user) throws IOException { @@ -199,6 +206,33 @@ public class UserModule extends PathHandler { return sendEmptyResponse(HTTP_UNAUTHORIZED,ex); } + private boolean getUserList(HttpExchange ex, UmbrellaUser user) throws IOException { + if (!(user instanceof DbUser dbUser && dbUser.permissions().contains(LIST_USERS))) return sendContent(ex,HTTP_FORBIDDEN,"You are not allowed to list users!"); + try { + var list = users.list(0, null).stream().map(UmbrellaUser::toMap).toList(); + return sendContent(ex,list); + } catch (UmbrellaException e) { + return sendContent(ex,e.statusCode(),e.getMessage()); + } + } + + private boolean impersonate(HttpExchange ex, Long targetId) throws IOException { + var sessionToken = SessionToken.from(ex).map(Token::of); + if (sessionToken.isEmpty()) return sendEmptyResponse(HTTP_UNAUTHORIZED,ex); + try { + var requestingUser = users.load(users.load(sessionToken.get())); + if (!(requestingUser instanceof DbUser dbUser && dbUser.permissions().contains(PERMISSION.IMPERSONATE))) return sendEmptyResponse(HTTP_FORBIDDEN,ex); + if (targetId == null) return sendContent(ex,HTTP_UNPROCESSABLE,"user id missing"); + var targetUser = users.load(targetId); + users.getSession(targetUser) + .cookie() + .addTo(ex.getResponseHeaders()); + return sendContent(ex,targetUser.toMap()); + } catch (UmbrellaException e) { + return sendContent(ex,e.statusCode(),e.getMessage()); + } + } + public boolean logout(HttpExchange ex, Optional optToken) throws IOException { if (optToken.isPresent()){ var token = optToken.get(); @@ -213,6 +247,29 @@ public class UserModule extends PathHandler { return sendEmptyResponse(HTTP_UNAUTHORIZED,ex); } + private boolean patchPassword(HttpExchange ex, UmbrellaUser requestingUser) throws IOException { + if (!(requestingUser instanceof DbUser user)) return sendContent(ex, HTTP_SERVER_ERROR,"DbUser expected"); + JSONObject json; + try { + json = json(ex); + } catch (Exception e){ + LOG.log(WARNING,"Request does not contain valid JSON",e); + return sendContent(ex,HTTP_BAD_REQUEST,"Body contains no JSON data"); + } + if (!json.has("old") || !(json.get("old") instanceof String oldpass) || oldpass.isBlank()) return sendContent(ex, HTTP_UNPROCESSABLE,"old password missing!"); + if (!json.has("new") || !(json.get("new") instanceof String newpass) || newpass.isBlank()) return sendContent(ex, HTTP_UNPROCESSABLE,"new password missing!"); + var old = Password.of(BAD_HASHER.hash(oldpass,null)); + if (!user.hashedPassword().equals(old)) return sendContent(ex,HTTP_UNAUTHORIZED,"Wrong password (old)"); + if (weak(newpass)) return sendContent(ex,HTTP_BAD_REQUEST,"New password too weak!"); + var pass = Password.of(BAD_HASHER.hash(newpass,null)); + try { + var updated = users.save(new DbUser(user.id(), user.name(), user.email(), pass, user.theme(), user.language(), user.permissions(), null)); + return sendContent(ex, updated); + } catch (UmbrellaException e) { + return sendContent(ex,e.statusCode(),e.getMessage()); + } + } + private boolean postLogin(HttpExchange ex) throws IOException { var json = json(ex); if (!(json.has(USERNAME) && json.get(USERNAME) instanceof String username)) return sendContent(ex, HTTP_UNPROCESSABLE,"Username missing"); @@ -230,6 +287,16 @@ public class UserModule extends PathHandler { } } + static int score(String password){ + if (password == null) return 0; + var score = 0; + for (int i=0; i