re-implemented login via OIDC
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user