re-implemented login via OIDC

This commit is contained in:
2025-07-03 15:26:42 +02:00
parent b9bff9733d
commit 41c3ffa351
11 changed files with 250 additions and 45 deletions

View File

@@ -6,6 +6,7 @@ dependencies{
implementation("de.srsoftware:tools.mime:1.1.2")
implementation("de.srsoftware:tools.optionals:1.0.0")
implementation("de.srsoftware:tools.util:2.0.3")
implementation("org.bitbucket.b_c:jose4j:0.9.6")
implementation("org.json:json:20240303")
implementation("org.xerial:sqlite-jdbc:3.49.0.0")
}

View File

@@ -1,18 +1,21 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.user;
import static de.srsoftware.tools.MimeType.MIME_FORM_URL;
import static de.srsoftware.tools.Optionals.*;
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.core.Util.open;
import static de.srsoftware.umbrella.core.Util.request;
import static de.srsoftware.umbrella.user.Constants.*;
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.lang.System.Logger.Level.*;
import static java.net.HttpURLConnection.*;
import static java.nio.charset.StandardCharsets.UTF_8;
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.UserDb;
import de.srsoftware.umbrella.user.model.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.*;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
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;
@@ -93,7 +100,7 @@ public class UserModule extends PathHandler {
switch (head) {
case LIST: return getUserList(ex, user);
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);
};
@@ -177,13 +184,88 @@ public class UserModule extends PathHandler {
head = path.pop();
} catch (NumberFormatException ignored) {}
switch (head){
case OIDC: return postOIDC(ex,path);
case IMPERSONATE: return impersonate(ex,targetId);
case LOGIN: return postLogin(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();
return switch (head){
case BUTTONS -> getOidcButtons(ex);
@@ -191,16 +273,19 @@ public class UserModule extends PathHandler {
case CONNECTED -> getConnectedServices(ex,user);
case REDIRECT -> getOidcRedirect(ex,path.pop());
case null -> super.doGet(path,ex);
default -> getService(ex,user,head);
default -> getOIDC(ex,user,head);
};
}
public static HttpURLConnection open(URL url) throws IOException {
var conn = (HttpURLConnection) url.openConnection();
conn.setRequestProperty("Accept","*/*");
conn.setRequestProperty("Host",url.getHost());
conn.setRequestProperty("User-Agent","Umbrella/0.1");
return conn;
private boolean getOIDC(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 JSONObject getOidcConfig(LoginService service) throws UmbrellaException {
@@ -230,7 +315,7 @@ public class UserModule extends PathHandler {
var returnTo = queryParam(ex).get("returnTo");
if (isSet(returnTo)) config.put("returnTo",returnTo);
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 clientId = loginService.clientId();
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 {
try {
var services = logins.listLoginServices().stream().map(LoginService::name);
@@ -334,6 +393,14 @@ public class UserModule extends PathHandler {
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 {
if (!(requestingUser instanceof DbUser user)) return sendContent(ex, HTTP_SERVER_ERROR,"DbUser expected");
JSONObject json;
@@ -387,6 +454,8 @@ public class UserModule extends PathHandler {
}
}
static int score(String password){
if (password == null) return 0;
var score = 0;
@@ -397,6 +466,10 @@ public class UserModule extends PathHandler {
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 {
var id = user.id();
var name = json.has(NAME) && json.get(NAME) instanceof String s && !s.isBlank() ? s : user.name();

View File

@@ -2,8 +2,7 @@
package de.srsoftware.umbrella.user.model;
import static de.srsoftware.umbrella.core.Constants.USER_ID;
import static de.srsoftware.umbrella.user.Constants.FOREIGN_ID;
import static de.srsoftware.umbrella.user.Constants.OIDC;
import static de.srsoftware.umbrella.user.Constants.*;
import de.srsoftware.tools.Mappable;
import java.util.Map;
@@ -15,6 +14,6 @@ public record ForeignLogin(String loginService, String foreingId, Long userId) i
@Override
public Map<String, Object> toMap() {
return Map.of(OIDC,loginService,FOREIGN_ID,foreingId, USER_ID,userId);
return Map.of(SERVICE_ID,loginService,FOREIGN_ID,foreingId, USER_ID,userId);
}
}