diff --git a/build.gradle b/build.gradle index 3da0194..8d79051 100644 --- a/build.gradle +++ b/build.gradle @@ -18,7 +18,7 @@ dependencies { implementation 'org.bouncycastle:bcprov-jdk15on:1.70' implementation 'com.github.FireMasterK.NewPipeExtractor:NewPipeExtractor:48beff184a9792c4787cfa05fce577c3adf89f56' implementation 'com.github.FireMasterK:nanojson:9f4af3b739cc13f3d0d9d4b758bbe2b2ae7119d7' - implementation 'com.nimbusds:oauth2-oidc-sdk:11.5.0' + implementation 'com.nimbusds:oauth2-oidc-sdk:11.5' implementation 'com.fasterxml.jackson.core:jackson-core:2.15.2' implementation 'com.fasterxml.jackson.core:jackson-annotations:2.15.2' implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' diff --git a/src/main/java/me/kavin/piped/server/ServerLauncher.java b/src/main/java/me/kavin/piped/server/ServerLauncher.java index a089b39..3394909 100644 --- a/src/main/java/me/kavin/piped/server/ServerLauncher.java +++ b/src/main/java/me/kavin/piped/server/ServerLauncher.java @@ -2,10 +2,10 @@ package me.kavin.piped.server; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; +import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.oauth2.sdk.*; import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; -import com.nimbusds.oauth2.sdk.id.Identifier; import com.nimbusds.oauth2.sdk.id.State; import com.nimbusds.openid.connect.sdk.*; import com.nimbusds.openid.connect.sdk.claims.UserInfo; @@ -28,6 +28,7 @@ import me.kavin.piped.server.handlers.auth.UserHandlers; import me.kavin.piped.utils.ErrorResponse; import me.kavin.piped.utils.*; import me.kavin.piped.utils.obj.MatrixHelper; +import me.kavin.piped.utils.obj.OidcData; import me.kavin.piped.utils.obj.OidcProvider; import me.kavin.piped.utils.obj.federation.FederatedVideoInfo; import me.kavin.piped.utils.resp.*; @@ -43,6 +44,8 @@ import org.xml.sax.InputSource; import java.io.ByteArrayInputStream; import java.net.InetSocketAddress; import java.net.URI; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; @@ -58,6 +61,7 @@ public class ServerLauncher extends MultithreadedHttpServerLauncher { private static final HttpHeader FILE_NAME = HttpHeaders.of("x-file-name"); private static final HttpHeader LAST_ETAG = HttpHeaders.of("x-last-etag"); + private static final Map PENDING_OIDC = new HashMap<>(); @Provides Executor executor() { @@ -285,7 +289,7 @@ public class ServerLauncher extends MultithreadedHttpServerLauncher { String function = request.getPathParameter("function"); OidcProvider provider = getOidcProvider(request.getPathParameter("provider")); if (provider == null) - return HttpResponse.ofCode(500).withHtml("Can't find the provider on the server."); + return HttpResponse.ofCode(500).withHtml("Can't find the provider on the server"); URI callback = new URI(Constants.PUBLIC_URL + "/oidc/" + provider.name + "/callback"); @@ -294,62 +298,62 @@ public class ServerLauncher extends MultithreadedHttpServerLauncher { String redirectUri = request.getQueryParameter("redirect"); if (StringUtils.isBlank(redirectUri)) { - return HttpResponse.ofCode(400).withHtml("Missing redirect parameter"); + return HttpResponse.ofCode(400).withHtml("redirect is a required parameter"); } - State state = new State(new Identifier(24) + "." + redirectUri); - Nonce nonce = new Nonce(); + OidcData data = new OidcData(redirectUri); + String state = data.getState(); + + PENDING_OIDC.put(state, data); AuthenticationRequest oidcRequest = new AuthenticationRequest.Builder( new ResponseType("code"), new Scope("openid"), - provider.clientID, - callback) - .endpointURI(provider.authUri) - .state(state) - .nonce(nonce) - .build(); + provider.clientID, callback).endpointURI(provider.authUri) + .state(new State(state)).nonce(data.nonce).build(); if (redirectUri.equals(Constants.FRONTEND_URL + "/login")) { return HttpResponse.redirect302(oidcRequest.toURI().toString()); } return HttpResponse.ok200().withHtml( - "" - + "

Warning:

You are trying to give
"
-                                                + redirectUri
-                                                + "
access to your Piped account. If you wish to continue click here"); + "" + + "

Warning:

You are trying to give
" +
+                                                redirectUri +
+                                                "
access to your Piped account. If you wish to continue click " + + "here"); } case "callback" -> { ClientAuthentication clientAuth = new ClientSecretBasic(provider.clientID, provider.clientSecret); - AuthenticationResponse response = AuthenticationResponseParser.parse( - URI.create(request.getFullUrl()) - ); + AuthenticationSuccessResponse sr = parseOidcUri(URI.create(request.getFullUrl())); - if (response instanceof AuthenticationErrorResponse) { - // The OpenID provider returned an error - System.err.println(response.toErrorResponse().getErrorObject()); - return HttpResponse.ofCode(500).withHtml("OpenID provider returned an error:\n\n" + response.toErrorResponse().getErrorObject().toString()); + OidcData data = PENDING_OIDC.get(sr.getState().toString()); + if (data == null) { + return HttpResponse.ofCode(400).withHtml( + "Your oidc provider sent invalid state data. Try again or contact your oidc admin" + ); } - AuthenticationSuccessResponse sr = response.toSuccessResponse(); - AuthorizationCode code = sr.getAuthorizationCode(); - AuthorizationGrant codeGrant = new AuthorizationCodeGrant( - code, callback - ); + AuthorizationGrant codeGrant = new AuthorizationCodeGrant(code, callback); + TokenRequest tr = new TokenRequest(provider.tokenUri, clientAuth, codeGrant); - TokenResponse tokenResponse = OIDCTokenResponseParser.parse(tr.toHTTPRequest().send()); + OIDCTokenResponse tokenResponse = (OIDCTokenResponse) OIDCTokenResponseParser.parse(tr.toHTTPRequest().send()); if (!tokenResponse.indicatesSuccess()) { TokenErrorResponse errorResponse = tokenResponse.toErrorResponse(); return HttpResponse.ofCode(500).withHtml("Failure while trying to request token:\n\n" + errorResponse.getErrorObject().getDescription()); } - OIDCTokenResponse successResponse = (OIDCTokenResponse) tokenResponse.toSuccessResponse(); + OIDCTokenResponse successResponse = tokenResponse.toSuccessResponse(); + if (data.isInvalidNonce((String) successResponse.getOIDCTokens().getIDToken().getJWTClaimsSet().getClaim("nonce"))) { + return HttpResponse.ofCode(400).withHtml( + "Your oidc provider sent an invalid nonce. Try again or contact your oidc admin" + ); + } UserInfoRequest ur = new UserInfoRequest(provider.userinfoUri, successResponse.getOIDCTokens().getBearerAccessToken()); UserInfoResponse userInfoResponse = UserInfoResponse.parse(ur.toHTTPRequest().send()); @@ -363,11 +367,57 @@ public class ServerLauncher extends MultithreadedHttpServerLauncher { UserInfo userInfo = userInfoResponse.toSuccessResponse().getUserInfo(); String sessionId = UserHandlers.oidcCallbackResponse(provider.name, userInfo.getSubject().toString()); + return HttpResponse.redirect302(data.data + "?session=" + sessionId); + } + case "delete" -> { + ClientAuthentication clientAuth = new ClientSecretBasic(provider.clientID, provider.clientSecret); - return HttpResponse.redirect302(sr.getState().toString().split("\\.", 2)[1] + "?session=" + sessionId); + AuthenticationSuccessResponse sr = parseOidcUri(URI.create(request.getFullUrl())); + + OidcData data = UserHandlers.PENDING_OIDC.get(sr.getState().toString()); + if (data == null) { + return HttpResponse.ofCode(400).withHtml( + "Your oidc provider sent invalid state data. Try again or contact your oidc admin" + ); + } + + long start = Long.parseLong(data.data.split("\\|")[1]); + String session = data.data.split("\\|")[0]; + + AuthorizationCode code = sr.getAuthorizationCode(); + AuthorizationGrant codeGrant = new AuthorizationCodeGrant(code, new URI(Constants.PUBLIC_URL + request.getPath())); + + + TokenRequest tr = new TokenRequest(provider.tokenUri, clientAuth, codeGrant); + TokenResponse tokenResponse = OIDCTokenResponseParser.parse(tr.toHTTPRequest().send()); + + if (!tokenResponse.indicatesSuccess()) { + TokenErrorResponse errorResponse = tokenResponse.toErrorResponse(); + return HttpResponse.ofCode(500).withHtml("Failure while trying to request token:\n\n" + errorResponse.getErrorObject().getDescription()); + } + + OIDCTokenResponse successResponse = (OIDCTokenResponse) tokenResponse.toSuccessResponse(); + + JWTClaimsSet claims = successResponse.getOIDCTokens().getIDToken().getJWTClaimsSet(); + + if (data.isInvalidNonce((String) claims.getClaim("nonce"))) { + return HttpResponse.ofCode(400).withHtml( + "Your oidc provider sent an invalid nonce. Please try again or contact your oidc admin." + ); + } + + long authTime = (long) claims.getClaim("auth_time"); + + if (authTime < start) { + return HttpResponse.ofCode(500).withHtml( + "Your oidc provider didn't verify your identity. Please try again or contact your oidc admin." + ); + } + + return HttpResponse.redirect302(Constants.FRONTEND_URL + "/preferences?deleted=" + UserHandlers.deleteOidcUserResponse(session)); } default -> { - return HttpResponse.ofCode(500).withHtml("Invalid function `" + function + "`."); + return HttpResponse.ofCode(500).withHtml("Invalid function `" + function + "`"); } } @@ -630,6 +680,17 @@ public class ServerLauncher extends MultithreadedHttpServerLauncher { return null; } + private static AuthenticationSuccessResponse parseOidcUri(URI uri) throws Exception { + AuthenticationResponse response = AuthenticationResponseParser.parse(uri); + + if (response instanceof AuthenticationErrorResponse) { + // The OpenID provider returned an error + System.err.println(response.toErrorResponse().getErrorObject()); + throw new Exception(response.toErrorResponse().getErrorObject().toString()); + } + return response.toSuccessResponse(); + } + private static String[] getArray(String s) { if (s == null) { diff --git a/src/main/java/me/kavin/piped/server/handlers/auth/UserHandlers.java b/src/main/java/me/kavin/piped/server/handlers/auth/UserHandlers.java index 7059bc2..207081f 100644 --- a/src/main/java/me/kavin/piped/server/handlers/auth/UserHandlers.java +++ b/src/main/java/me/kavin/piped/server/handlers/auth/UserHandlers.java @@ -1,6 +1,10 @@ package me.kavin.piped.server.handlers.auth; import com.fasterxml.jackson.core.JsonProcessingException; +import com.nimbusds.oauth2.sdk.ResponseType; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.id.State; +import com.nimbusds.openid.connect.sdk.AuthenticationRequest; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Root; @@ -9,6 +13,8 @@ import me.kavin.piped.utils.DatabaseHelper; import me.kavin.piped.utils.DatabaseSessionFactory; import me.kavin.piped.utils.ExceptionHandler; import me.kavin.piped.utils.RequestUtils; +import me.kavin.piped.utils.obj.OidcData; +import me.kavin.piped.utils.obj.OidcProvider; import me.kavin.piped.utils.obj.db.User; import me.kavin.piped.utils.resp.*; import org.apache.commons.codec.digest.DigestUtils; @@ -19,6 +25,10 @@ import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import java.io.IOException; +import java.net.URI; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; import java.util.Set; import java.util.UUID; @@ -27,6 +37,7 @@ import static me.kavin.piped.consts.Constants.mapper; public class UserHandlers { private static final Argon2PasswordEncoder argon2PasswordEncoder = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); private static final BCryptPasswordEncoder bcryptPasswordEncoder = new BCryptPasswordEncoder(); + public static final Map PENDING_OIDC = new HashMap<>(); public static byte[] registerResponse(String user, String pass) throws Exception { @@ -111,6 +122,7 @@ public class UserHandlers { public static String oidcCallbackResponse(String provider, String uid) { try (Session s = DatabaseSessionFactory.createSession()) { + // TODO: Add oidc provider to database String dbName = provider + "-" + uid; CriteriaBuilder cb = s.getCriteriaBuilder(); CriteriaQuery cr = cb.createQuery(User.class); @@ -148,12 +160,21 @@ public class UserHandlers { String hash = user.getPassword(); - if (hash.equals("")) { - //TODO: Authorize against oidc provider before deletion - var tr = s.beginTransaction(); - s.remove(user); - tr.commit(); - return mapper.writeValueAsBytes(new DeleteUserResponse(user.getUsername())); + if (hash.isEmpty()) { + //TODO: Get user from oidc table and lookup provider + OidcProvider provider = Constants.OIDC_PROVIDERS.get(0); + URI callback = URI.create(String.format("%s/oidc/%s/delete", Constants.PUBLIC_URL, provider.name)); + OidcData data = new OidcData(session + "|" + Instant.now().getEpochSecond()); + String state = data.getState(); + PENDING_OIDC.put(state, data); + + AuthenticationRequest oidcRequest = new AuthenticationRequest.Builder( + new ResponseType("code"), + new Scope("openid"), provider.clientID, callback).endpointURI(provider.authUri) + .state(new State(state)).nonce(data.nonce).maxAge(0).build(); + + + return String.format("{\"redirect\": \"%s\"}", oidcRequest.toURI().toString()).getBytes(); } if (!hashMatch(hash, pass)) ExceptionHandler.throwErrorResponse(new IncorrectCredentialsResponse()); @@ -166,6 +187,18 @@ public class UserHandlers { } } + public static String deleteOidcUserResponse(String session) throws IOException { + try (Session s = DatabaseSessionFactory.createSession()) { + User user = DatabaseHelper.getUserFromSession(session); + + var tr = s.beginTransaction(); + s.remove(user); + tr.commit(); + + return user.getUsername(); + } + } + public static byte[] logoutResponse(String session) throws JsonProcessingException { if (StringUtils.isBlank(session)) diff --git a/src/main/java/me/kavin/piped/utils/obj/OidcData.java b/src/main/java/me/kavin/piped/utils/obj/OidcData.java new file mode 100644 index 0000000..f1bfd6c --- /dev/null +++ b/src/main/java/me/kavin/piped/utils/obj/OidcData.java @@ -0,0 +1,36 @@ +package me.kavin.piped.utils.obj; + +import com.nimbusds.openid.connect.sdk.Nonce; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +public class OidcData { + public final Nonce nonce; + + public String data; + + public OidcData(String data) { + this.nonce = new Nonce(); + this.data = data; + } + + public boolean isInvalidNonce(String nonce) { + return !nonce.equals(this.nonce.toString()); + } + + public String getState() { + String value = nonce + data; + + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(value.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not supported", e); + } + } +}