|
|
|
@ -1,18 +1,21 @@ |
|
|
|
/* © SRSoftware 2025 */ |
|
|
|
/* © SRSoftware 2025 */ |
|
|
|
package de.srsoftware.umbrella.user; |
|
|
|
package de.srsoftware.umbrella.user; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import static de.srsoftware.tools.MimeType.MIME_FORM_URL; |
|
|
|
import static de.srsoftware.tools.Optionals.*; |
|
|
|
import static de.srsoftware.tools.Optionals.*; |
|
|
|
import static de.srsoftware.umbrella.core.Constants.*; |
|
|
|
import static de.srsoftware.umbrella.core.Constants.*; |
|
|
|
import static de.srsoftware.umbrella.core.Paths.LIST; |
|
|
|
import static de.srsoftware.umbrella.core.Paths.LIST; |
|
|
|
import static de.srsoftware.umbrella.core.Paths.LOGOUT; |
|
|
|
import static de.srsoftware.umbrella.core.Paths.LOGOUT; |
|
|
|
import static de.srsoftware.umbrella.core.ResponseCode.*; |
|
|
|
import static de.srsoftware.umbrella.core.ResponseCode.*; |
|
|
|
import static de.srsoftware.umbrella.core.ResponseCode.HTTP_SERVER_ERROR; |
|
|
|
import static de.srsoftware.umbrella.core.ResponseCode.HTTP_SERVER_ERROR; |
|
|
|
|
|
|
|
import static de.srsoftware.umbrella.core.Util.open; |
|
|
|
|
|
|
|
import static de.srsoftware.umbrella.core.Util.request; |
|
|
|
import static de.srsoftware.umbrella.user.Constants.*; |
|
|
|
import static de.srsoftware.umbrella.user.Constants.*; |
|
|
|
import static de.srsoftware.umbrella.user.Paths.*; |
|
|
|
import static de.srsoftware.umbrella.user.Paths.*; |
|
|
|
import static de.srsoftware.umbrella.user.Paths.IMPERSONATE; |
|
|
|
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 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.lang.System.Logger.Level.*; |
|
|
|
import static java.net.HttpURLConnection.*; |
|
|
|
import static java.net.HttpURLConnection.*; |
|
|
|
import static java.nio.charset.StandardCharsets.UTF_8; |
|
|
|
import static java.nio.charset.StandardCharsets.UTF_8; |
|
|
|
import static java.text.MessageFormat.format; |
|
|
|
import static java.text.MessageFormat.format; |
|
|
|
@ -26,14 +29,18 @@ import de.srsoftware.umbrella.core.UmbrellaException; |
|
|
|
import de.srsoftware.umbrella.user.api.LoginServiceDb; |
|
|
|
import de.srsoftware.umbrella.user.api.LoginServiceDb; |
|
|
|
import de.srsoftware.umbrella.user.api.UserDb; |
|
|
|
import de.srsoftware.umbrella.user.api.UserDb; |
|
|
|
import de.srsoftware.umbrella.user.model.*; |
|
|
|
import de.srsoftware.umbrella.user.model.*; |
|
|
|
|
|
|
|
|
|
|
|
import java.io.ByteArrayOutputStream; |
|
|
|
import java.io.ByteArrayOutputStream; |
|
|
|
import java.io.IOException; |
|
|
|
import java.io.IOException; |
|
|
|
import java.net.*; |
|
|
|
import java.net.*; |
|
|
|
import java.security.NoSuchAlgorithmException; |
|
|
|
import java.security.NoSuchAlgorithmException; |
|
|
|
import java.time.Instant; |
|
|
|
import java.time.Instant; |
|
|
|
import java.util.*; |
|
|
|
import java.util.*; |
|
|
|
|
|
|
|
import org.jose4j.jwk.HttpsJwks; |
|
|
|
|
|
|
|
import org.jose4j.jwt.MalformedClaimException; |
|
|
|
|
|
|
|
import org.jose4j.jwt.consumer.InvalidJwtException; |
|
|
|
|
|
|
|
import org.jose4j.jwt.consumer.JwtConsumer; |
|
|
|
|
|
|
|
import org.jose4j.jwt.consumer.JwtConsumerBuilder; |
|
|
|
|
|
|
|
import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver; |
|
|
|
import org.json.JSONObject; |
|
|
|
import org.json.JSONObject; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -93,7 +100,7 @@ public class UserModule extends PathHandler { |
|
|
|
switch (head) { |
|
|
|
switch (head) { |
|
|
|
case LIST: return getUserList(ex, user); |
|
|
|
case LIST: return getUserList(ex, user); |
|
|
|
case LOGOUT: return logout(ex, sessionToken); |
|
|
|
case LOGOUT: return logout(ex, sessionToken); |
|
|
|
case OIDC: return getService(ex,user,path); |
|
|
|
case OIDC: return getOIDC(ex,user,path); |
|
|
|
case WHOAMI: return getUser(ex, user); |
|
|
|
case WHOAMI: return getUser(ex, user); |
|
|
|
|
|
|
|
|
|
|
|
}; |
|
|
|
}; |
|
|
|
@ -177,13 +184,88 @@ public class UserModule extends PathHandler { |
|
|
|
head = path.pop(); |
|
|
|
head = path.pop(); |
|
|
|
} catch (NumberFormatException ignored) {} |
|
|
|
} catch (NumberFormatException ignored) {} |
|
|
|
switch (head){ |
|
|
|
switch (head){ |
|
|
|
|
|
|
|
case OIDC: return postOIDC(ex,path); |
|
|
|
case IMPERSONATE: return impersonate(ex,targetId); |
|
|
|
case IMPERSONATE: return impersonate(ex,targetId); |
|
|
|
case LOGIN: return postLogin(ex); |
|
|
|
case LOGIN: return postLogin(ex); |
|
|
|
} |
|
|
|
} |
|
|
|
return super.doPost(path, ex); |
|
|
|
return super.doPost(path, ex); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private boolean getService(HttpExchange ex, UmbrellaUser user, Path path) throws IOException { |
|
|
|
private boolean exchangeToken(HttpExchange ex) throws IOException { |
|
|
|
|
|
|
|
JSONObject params; |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
params = json(ex); |
|
|
|
|
|
|
|
} catch (Exception e) { |
|
|
|
|
|
|
|
LOG.log(WARNING, "Request does not seem to contain JSON data!", e); |
|
|
|
|
|
|
|
return sendContent(ex,HTTP_BAD_REQUEST,"Request does not seem to contain JSON data!"); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (!params.has(CODE)) return sendContent(ex,HTTP_BAD_REQUEST,"missing auth code"); |
|
|
|
|
|
|
|
if (!params.has(STATE)) return sendContent(ex,HTTP_BAD_REQUEST,"no state submitted"); |
|
|
|
|
|
|
|
var code = params.getString(CODE); |
|
|
|
|
|
|
|
var state = stateMep.remove(params.getString(STATE)); |
|
|
|
|
|
|
|
if (state == null) return sendContent(ex,HTTP_BAD_REQUEST,"no state submitted"); |
|
|
|
|
|
|
|
var redirect = url(ex).replaceAll("/api/.*",""); |
|
|
|
|
|
|
|
var location = state.config.getString(TOKEN_ENDPOINT); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
var data = Map.of(GRANT_TYPE,AUTHORIZATION_CODE, CODE,code,REDIRECT_URI,redirect); |
|
|
|
|
|
|
|
var resp = request(location,data,MIME_FORM_URL,state.loginService.basicAuth()); |
|
|
|
|
|
|
|
if (!(resp instanceof JSONObject json)) return sendContent(ex,HTTP_BAD_REQUEST,format("{0} did not return JSON!",location)); |
|
|
|
|
|
|
|
if (!json.has(ID_TOKEN)) return sendContent(ex,HTTP_FAILED_DEPENDENCY,"Missing ID token – token exchange failed!"); |
|
|
|
|
|
|
|
var idToken = json.getString(ID_TOKEN); |
|
|
|
|
|
|
|
Optional<Token> optToken = SessionToken.from(ex).map(Token::of); |
|
|
|
|
|
|
|
Optional<UmbrellaUser> optUser = Optional.empty(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (optToken.isPresent()){ |
|
|
|
|
|
|
|
Session session = users.load(optToken.get()); |
|
|
|
|
|
|
|
optUser = Optional.of(users.load(session)); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
var oidcUserId = verifyAndGetUserId(idToken, state); |
|
|
|
|
|
|
|
if (optUser.isPresent()){ // user already logged in – this is the case when the new id gets assigned
|
|
|
|
|
|
|
|
var currentUser = optUser.get(); |
|
|
|
|
|
|
|
var assignment = new ForeignLogin(state.loginService.name(),oidcUserId,currentUser.id()); |
|
|
|
|
|
|
|
logins.save(assignment); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
var user = users.load(logins.getUserId(state.loginService.name(), oidcUserId)); |
|
|
|
|
|
|
|
var session = users.getSession(user); |
|
|
|
|
|
|
|
var returnTo = "/user"; |
|
|
|
|
|
|
|
if (state.config.has("returnTo")) returnTo = state.config.getString("returnTo"); |
|
|
|
|
|
|
|
session.cookie().addTo(ex); |
|
|
|
|
|
|
|
return sendContent(ex,Map.of(REDIRECT,returnTo)); |
|
|
|
|
|
|
|
} catch (UmbrellaException e){ |
|
|
|
|
|
|
|
return send(ex,e); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private String verifyAndGetUserId(String jwt, State state) throws UmbrellaException { |
|
|
|
|
|
|
|
var jwksEndpoint = state.config.getString(JWKS_ENDPOINT); |
|
|
|
|
|
|
|
var audience = state.loginService.clientId(); |
|
|
|
|
|
|
|
var httpJwks = new HttpsJwks(jwksEndpoint); |
|
|
|
|
|
|
|
var resolver = new HttpsJwksVerificationKeyResolver(httpJwks); |
|
|
|
|
|
|
|
JwtConsumer consumer = new JwtConsumerBuilder() |
|
|
|
|
|
|
|
.setVerificationKeyResolver(resolver) |
|
|
|
|
|
|
|
.setExpectedAudience(audience) |
|
|
|
|
|
|
|
.build(); |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
var claims = consumer.processToClaims(jwt); |
|
|
|
|
|
|
|
return claims.getSubject(); |
|
|
|
|
|
|
|
} catch (InvalidJwtException | MalformedClaimException e) { |
|
|
|
|
|
|
|
throw new UmbrellaException(500,"Failed to verify JWT!").causedBy(e); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private boolean getConnectedServices(HttpExchange ex, UmbrellaUser user) throws IOException { |
|
|
|
|
|
|
|
if (user == null) return sendEmptyResponse(HTTP_UNAUTHORIZED,ex); |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
var connections = logins.listAssignments(user.id()).stream().map(ForeignLogin::toMap); |
|
|
|
|
|
|
|
return sendContent(ex,connections); |
|
|
|
|
|
|
|
} catch (UmbrellaException e) { |
|
|
|
|
|
|
|
return sendContent(ex,e.statusCode(),e.getMessage()); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private boolean getOIDC(HttpExchange ex, UmbrellaUser user, Path path) throws IOException { |
|
|
|
var head = path.pop(); |
|
|
|
var head = path.pop(); |
|
|
|
return switch (head){ |
|
|
|
return switch (head){ |
|
|
|
case BUTTONS -> getOidcButtons(ex); |
|
|
|
case BUTTONS -> getOidcButtons(ex); |
|
|
|
@ -191,16 +273,19 @@ public class UserModule extends PathHandler { |
|
|
|
case CONNECTED -> getConnectedServices(ex,user); |
|
|
|
case CONNECTED -> getConnectedServices(ex,user); |
|
|
|
case REDIRECT -> getOidcRedirect(ex,path.pop()); |
|
|
|
case REDIRECT -> getOidcRedirect(ex,path.pop()); |
|
|
|
case null -> super.doGet(path,ex); |
|
|
|
case null -> super.doGet(path,ex); |
|
|
|
default -> getService(ex,user,head); |
|
|
|
default -> getOIDC(ex,user,head); |
|
|
|
}; |
|
|
|
}; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
public static HttpURLConnection open(URL url) throws IOException { |
|
|
|
private boolean getOIDC(HttpExchange ex, UmbrellaUser user, String serviceId) throws IOException { |
|
|
|
var conn = (HttpURLConnection) url.openConnection(); |
|
|
|
if (!(user instanceof DbUser dbUser && dbUser.permissions().contains(MANAGE_LOGIN_SERVICES))) return sendEmptyResponse(HTTP_FORBIDDEN,ex); |
|
|
|
conn.setRequestProperty("Accept","*/*"); |
|
|
|
try { |
|
|
|
conn.setRequestProperty("Host",url.getHost()); |
|
|
|
return sendContent(ex,logins.loadLoginService(serviceId).toMap()); |
|
|
|
conn.setRequestProperty("User-Agent","Umbrella/0.1"); |
|
|
|
} catch (UmbrellaException e) { |
|
|
|
return conn; |
|
|
|
return sendContent(ex,e.statusCode(),e.getMessage()); |
|
|
|
|
|
|
|
} catch (IOException e) { |
|
|
|
|
|
|
|
return sendContent(ex,HTTP_SERVER_ERROR,e.getMessage()); |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private JSONObject getOidcConfig(LoginService service) throws UmbrellaException { |
|
|
|
private JSONObject getOidcConfig(LoginService service) throws UmbrellaException { |
|
|
|
@ -230,7 +315,7 @@ public class UserModule extends PathHandler { |
|
|
|
var returnTo = queryParam(ex).get("returnTo"); |
|
|
|
var returnTo = queryParam(ex).get("returnTo"); |
|
|
|
if (isSet(returnTo)) config.put("returnTo",returnTo); |
|
|
|
if (isSet(returnTo)) config.put("returnTo",returnTo); |
|
|
|
var url = url(ex); |
|
|
|
var url = url(ex); |
|
|
|
var callback = url.replaceAll("/api/.*", "/callback"); // TODO: frontendPath an zweiter stelle
|
|
|
|
var callback = url.replaceAll("/api/.*", "/oidc_callback"); // TODO: frontendPath an zweiter stelle
|
|
|
|
var authEndpoint = config.getString(AUTH_ENDPOINT); |
|
|
|
var authEndpoint = config.getString(AUTH_ENDPOINT); |
|
|
|
var clientId = loginService.clientId(); |
|
|
|
var clientId = loginService.clientId(); |
|
|
|
var state = UUID.randomUUID().toString(); |
|
|
|
var state = UUID.randomUUID().toString(); |
|
|
|
@ -241,32 +326,6 @@ public class UserModule extends PathHandler { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private boolean send(HttpExchange ex, UmbrellaException e) throws IOException { |
|
|
|
|
|
|
|
return sendContent(ex,e.statusCode(),e.getMessage()); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private boolean getConnectedServices(HttpExchange ex, UmbrellaUser user) throws IOException { |
|
|
|
|
|
|
|
if (user == null) return sendEmptyResponse(HTTP_UNAUTHORIZED,ex); |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
var connections = logins.listAssignments(user.id()).stream().map(ForeignLogin::toMap); |
|
|
|
|
|
|
|
return sendContent(ex,connections); |
|
|
|
|
|
|
|
} catch (UmbrellaException e) { |
|
|
|
|
|
|
|
return sendContent(ex,e.statusCode(),e.getMessage()); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private boolean getService(HttpExchange ex, UmbrellaUser user, String serviceId) throws IOException { |
|
|
|
|
|
|
|
if (!(user instanceof DbUser dbUser && dbUser.permissions().contains(MANAGE_LOGIN_SERVICES))) return sendEmptyResponse(HTTP_FORBIDDEN,ex); |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
return sendContent(ex,logins.loadLoginService(serviceId).toMap()); |
|
|
|
|
|
|
|
} catch (UmbrellaException e) { |
|
|
|
|
|
|
|
return sendContent(ex,e.statusCode(),e.getMessage()); |
|
|
|
|
|
|
|
} catch (IOException e) { |
|
|
|
|
|
|
|
return sendContent(ex,HTTP_SERVER_ERROR,e.getMessage()); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private boolean getOidcButtons(HttpExchange ex) throws IOException { |
|
|
|
private boolean getOidcButtons(HttpExchange ex) throws IOException { |
|
|
|
try { |
|
|
|
try { |
|
|
|
var services = logins.listLoginServices().stream().map(LoginService::name); |
|
|
|
var services = logins.listLoginServices().stream().map(LoginService::name); |
|
|
|
@ -334,6 +393,14 @@ public class UserModule extends PathHandler { |
|
|
|
return sendEmptyResponse(HTTP_UNAUTHORIZED,ex); |
|
|
|
return sendEmptyResponse(HTTP_UNAUTHORIZED,ex); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private boolean postOIDC(HttpExchange ex, Path path) throws IOException { |
|
|
|
|
|
|
|
return switch (path.pop()){ |
|
|
|
|
|
|
|
case TOKEN -> exchangeToken(ex); |
|
|
|
|
|
|
|
case null, default -> super.doPost(path,ex); |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private boolean patchPassword(HttpExchange ex, UmbrellaUser requestingUser) throws IOException { |
|
|
|
private boolean patchPassword(HttpExchange ex, UmbrellaUser requestingUser) throws IOException { |
|
|
|
if (!(requestingUser instanceof DbUser user)) return sendContent(ex, HTTP_SERVER_ERROR,"DbUser expected"); |
|
|
|
if (!(requestingUser instanceof DbUser user)) return sendContent(ex, HTTP_SERVER_ERROR,"DbUser expected"); |
|
|
|
JSONObject json; |
|
|
|
JSONObject json; |
|
|
|
@ -387,6 +454,8 @@ public class UserModule extends PathHandler { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static int score(String password){ |
|
|
|
static int score(String password){ |
|
|
|
if (password == null) return 0; |
|
|
|
if (password == null) return 0; |
|
|
|
var score = 0; |
|
|
|
var score = 0; |
|
|
|
@ -397,6 +466,10 @@ public class UserModule extends PathHandler { |
|
|
|
return score; |
|
|
|
return score; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private boolean send(HttpExchange ex, UmbrellaException e) throws IOException { |
|
|
|
|
|
|
|
return sendContent(ex,e.statusCode(),e.getMessage()); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private boolean update(HttpExchange ex, DbUser user, JSONObject json) throws UmbrellaException, IOException { |
|
|
|
private boolean update(HttpExchange ex, DbUser user, JSONObject json) throws UmbrellaException, IOException { |
|
|
|
var id = user.id(); |
|
|
|
var id = user.id(); |
|
|
|
var name = json.has(NAME) && json.get(NAME) instanceof String s && !s.isBlank() ? s : user.name(); |
|
|
|
var name = json.has(NAME) && json.get(NAME) instanceof String s && !s.isBlank() ? s : user.name(); |
|
|
|
|