Browse Source

re-implemented login via OIDC

feature/document
Stephan Richter 4 months ago
parent
commit
41c3ffa351
  1. 2
      core/build.gradle.kts
  2. 5
      core/src/main/java/de/srsoftware/umbrella/core/Constants.java
  3. 1
      core/src/main/java/de/srsoftware/umbrella/core/ResponseCode.java
  4. 88
      core/src/main/java/de/srsoftware/umbrella/core/Util.java
  5. 6
      frontend/src/App.svelte
  6. 2
      frontend/src/routes/user/ConnectedServices.svelte
  7. 33
      frontend/src/routes/user/OidcCallback.svelte
  8. 1
      translations/src/main/resources/de.json
  9. 1
      user/build.gradle.kts
  10. 151
      user/src/main/java/de/srsoftware/umbrella/user/UserModule.java
  11. 5
      user/src/main/java/de/srsoftware/umbrella/user/model/ForeignLogin.java

2
core/build.gradle.kts

@ -10,6 +10,8 @@ repositories { @@ -10,6 +10,8 @@ repositories {
}
dependencies {
implementation("de.srsoftware:tools.mime:1.1.2")
implementation("org.json:json:20240303")
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
implementation("org.xerial:sqlite-jdbc:3.49.0.0")

5
core/src/main/java/de/srsoftware/umbrella/core/Constants.java

@ -8,7 +8,9 @@ public class Constants { @@ -8,7 +8,9 @@ public class Constants {
public static final String ADDRESS = "address";
public static final String ATTACHMENTS = "attachments";
public static final String AUTHORIZATION = "Authorization";
public static final String BODY = "body";
public static final String CONTENT_TYPE = "Content-Type";
public static final String DATA = "data";
public static final String DATE = "date";
public static final String DEFAULT_LANGUAGE = "en";
@ -23,6 +25,8 @@ public class Constants { @@ -23,6 +25,8 @@ public class Constants {
public static final String ERROR_READ_TABLE = "Failed to read {0} from {1} table";
public static final String EXPIRATION = "expiration";
public static final String GET = "GET";
public static final String ID = "id";
public static final String KEY = "key";
public static final String LANGUAGE = "language";
@ -33,6 +37,7 @@ public class Constants { @@ -33,6 +37,7 @@ public class Constants {
public static final String NUMBER = "number";
public static final String OPTIONAL = "optional";
public static final String PASSWORD = "password";
public static final String POST = "POST";
public static final String RECEIVERS = "receivers";
public static final String REDIRECT = "redirect";

1
core/src/main/java/de/srsoftware/umbrella/core/ResponseCode.java

@ -3,6 +3,7 @@ package de.srsoftware.umbrella.core; @@ -3,6 +3,7 @@ package de.srsoftware.umbrella.core;
public class ResponseCode {
public static final int HTTP_UNPROCESSABLE = 422;
public static final int HTTP_FAILED_DEPENDENCY = 424;
public static final int HTTP_SERVER_ERROR = 500;
public static final int HTTP_NOT_IMPLEMENTED = 501;
}

88
core/src/main/java/de/srsoftware/umbrella/core/Util.java

@ -0,0 +1,88 @@ @@ -0,0 +1,88 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.core;
import static de.srsoftware.tools.MimeType.MIME_FORM_URL;
import static de.srsoftware.tools.MimeType.MIME_JSON;
import static de.srsoftware.umbrella.core.Constants.*;
import static java.lang.System.Logger.Level.*;
import static java.lang.System.Logger.Level.WARNING;
import static java.nio.charset.StandardCharsets.UTF_8;
import de.srsoftware.tools.Query;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.util.Map;
import org.json.JSONObject;
public class Util {
public static final System.Logger LOG = System.getLogger("Util");
private Util(){}
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;
}
public static Object request(String location, Map<String,?> data, String postMime, String auth) throws UmbrellaException {
URL url;
try {
url = new URI(location).toURL();
} catch (Exception e) {
LOG.log(WARNING,"{0} is not a valid url",location,e);
throw new UmbrellaException(500,"{0} is not a valid url",location).causedBy(e);
}
return request(url,data,postMime,auth);
}
public static Object request(URL target, Map<String,?> data, String postMime, String auth) throws UmbrellaException {
String query = null;
if (data != null) {
query = switch (postMime){
case MIME_FORM_URL -> Query.encode(data).orElse(null);
case null, default -> {
postMime = MIME_JSON;
yield new JSONObject(data).toString();
}
};
}
var method = query == null ? GET : POST;
try {
LOG.log(DEBUG,"sending {0} request ({1}) to {2}",method,postMime == null ? "empty" : postMime,target);
LOG.log(TRACE,"postData = {0}",query);
var conn = open(target);
conn.setRequestMethod(method);
conn.setRequestProperty(CONTENT_TYPE, postMime);
if (auth != null) conn.setRequestProperty(AUTHORIZATION,auth);
if (query != null) {
conn.setDoOutput(true);
var out = conn.getOutputStream();
out.write(query.getBytes(UTF_8));
out.flush();
out.close();
}
var bos = new ByteArrayOutputStream();
if (conn.getResponseCode() == 200) {
var is = conn.getInputStream();
is.transferTo(bos);
is.close();
var content = bos.toString(UTF_8);
if (content.startsWith("{")) return new JSONObject(content);
return content;
} else {
var is = conn.getErrorStream();
is.transferTo(bos);
is.close();
throw new UmbrellaException(500,bos.toString(UTF_8));
}
} catch (Exception e) {
LOG.log(WARNING,"Request to {0} failed: {1}",target,e.getMessage());
throw new UmbrellaException(500,"Request to {0} failed!",target).causedBy(e);
}
}
}

6
frontend/src/App.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import { loadTranslation } from './translations.svelte.js';
import { user } from './user.svelte.js';
import { Router, Route } from 'svelte-tiny-router';
import Callback from "./routes/user/OidcCallback.svelte";
import EditService from "./routes/user/EditService.svelte";
import Footer from "./Components/Footer.svelte";
import Login from "./Components/Login.svelte";
@ -26,9 +27,9 @@ @@ -26,9 +27,9 @@
</script>
{#if translations_ready }
<Router>
{#if user.name }
<!-- https://github.com/notnotsamuel/svelte-tiny-router -->
<Router>
<Menu />
<Route path="/user" component={User} />
<Route path="/user/:user_id/edit" component={UserEdit} />
@ -36,10 +37,11 @@ @@ -36,10 +37,11 @@
<Route>
<p>Page not found</p>
</Route>
</Router>
{:else}
<Login />
<Route path="/oidc_callback" component={Callback} />
{/if}
</Router>
<Footer />
{:else}
<p>Loading translations...</p>

2
frontend/src/routes/user/ConnectedServices.svelte

@ -33,7 +33,7 @@ @@ -33,7 +33,7 @@
<tbody>
{#each connections as connection,i}
<tr>
<td>{connection.service}</td>
<td>{connection.service_id}</td>
<td>{connection.foreign_id}</td>
<td>
<button>{t('user.unlink')}</button>

33
frontend/src/routes/user/OidcCallback.svelte

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
<script>
import { onMount } from 'svelte';
import { t } from '../../translations.svelte.js';
import { useTinyRouter } from 'svelte-tiny-router';
import { checkUser } from '../../user.svelte.js';
const router = useTinyRouter();
let message = $state(t('user.processing_code'));
onMount(async () => {
let params = new URLSearchParams(location.search);
if (params.get('code')){
const url = `${location.protocol}//${location.host.replace('5173','8080')}/api/user/oidc/token`;
const resp = await fetch(url,{
method : 'POST',
body: JSON.stringify(Object.fromEntries(params)),
credentials: 'include'
});
if (resp.ok){
let json = await resp.json();
const redirect = json.redirect ? json.redirect : '/user';
checkUser();
router.navigate(redirect);
} else {
message = await resp.text();
if (!message) message = t(resp);
}
}
});
</script>
{message}

1
translations/src/main/resources/de.json

@ -19,6 +19,7 @@ @@ -19,6 +19,7 @@
},
"status" : {
"403": "Zugriff verweigert",
"404": "Seite nicht gefunden",
"501": "Nicht implementiert"
},
"user" : {

1
user/build.gradle.kts

@ -6,6 +6,7 @@ dependencies{ @@ -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")
}

151
user/src/main/java/de/srsoftware/umbrella/user/UserModule.java

@ -1,18 +1,21 @@ @@ -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; @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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();

5
user/src/main/java/de/srsoftware/umbrella/user/model/ForeignLogin.java

@ -2,8 +2,7 @@ @@ -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 @@ -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);
}
}

Loading…
Cancel
Save