mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
wip - configuring resource server
This commit is contained in:
parent
0b224c8547
commit
41dfb1c4f6
2
.gitignore
vendored
2
.gitignore
vendored
@ -18,6 +18,7 @@ version.properties
|
||||
|
||||
#### Stirling-PDF Files ###
|
||||
pipeline/watchedFolders/
|
||||
pipeline/defaultWebUIConfigs/
|
||||
pipeline/finishedFolders/
|
||||
customFiles/
|
||||
configs/
|
||||
@ -200,3 +201,4 @@ id_ed25519.pub
|
||||
|
||||
# node_modules
|
||||
node_modules/
|
||||
app/core/src/main/resources/application-saas.yml
|
||||
|
@ -372,7 +372,7 @@ public class ApplicationProperties {
|
||||
public String getBaseTmpDir() {
|
||||
return baseTmpDir != null && !baseTmpDir.isEmpty()
|
||||
? baseTmpDir
|
||||
: java.lang.System.getProperty("java.io.tmpdir") + "/stirling-pdf";
|
||||
: java.lang.System.getProperty("java.io.tmpdir") + "stirling-pdf";
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
|
@ -1,72 +0,0 @@
|
||||
spring:
|
||||
main.allow-bean-definition-overriding: true
|
||||
web.resources.mime-mappings.webmanifest: application/manifest+json
|
||||
mvc.async.request-timeout: ${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000}
|
||||
thymeleaf.encoding: UTF-8
|
||||
jpa.open-in-view: false
|
||||
servlet.multipart:
|
||||
max-file-size: 2000MB
|
||||
max-request-size: 2000MB
|
||||
devtools:
|
||||
restart:
|
||||
enabled: true
|
||||
exclude: stirling.software.proprietary.security/**
|
||||
livereload.enabled: true
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
url: jdbc:postgresql://db.nrlkjfznsavsbmweiyqu.supabase.co:5432/postgres?sslmode=require
|
||||
username: postgres
|
||||
password: ${SAAS_DB_PASSWORD}
|
||||
jpa:
|
||||
hibernate.ddl-auto: update
|
||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||
security:
|
||||
oauth2:
|
||||
res
|
||||
|
||||
multipart.enabled: true
|
||||
logging.level:
|
||||
org:
|
||||
springframework: WARN
|
||||
hibernate: WARN
|
||||
jetty: WARN
|
||||
# springframework.security.saml2: TRACE
|
||||
# springframework.security: DEBUG
|
||||
# opensaml: DEBUG
|
||||
# stirling.software.SPDF.config.security: DEBUG
|
||||
com.zaxxer.hikari: WARN
|
||||
|
||||
server:
|
||||
forward-headers-strategy: NATIVE
|
||||
error:
|
||||
path: /error
|
||||
whitelabel.enabled: false
|
||||
include-stacktrace: always
|
||||
include-exception: true
|
||||
include-message: always
|
||||
servlet:
|
||||
session:
|
||||
timeout: 30m
|
||||
tracking-modes: cookie
|
||||
context-path: ${SYSTEM_ROOTURIPATH:/}
|
||||
|
||||
# Change the default URL path for OpenAPI JSON
|
||||
springdoc:
|
||||
api-docs.path: /v1/api-docs
|
||||
swagger-ui:
|
||||
url: /v1/api-docs
|
||||
path: /index.html
|
||||
|
||||
posthog:
|
||||
api:
|
||||
key: ${POSTHOG_API_KEY:phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq}
|
||||
host: ${POSTHOG_HOST:https://eu.i.posthog.com}
|
||||
|
||||
supabase:
|
||||
id: ${SUPABASE_PROJECT_ID:nrlkjfznsavsbmweiyqu}
|
||||
publishable-key: ${SUPABASE_PUBLISHABLE_KEY:rp7EBq9Dk-ZPCSe7EMWD7JZ6rMulReSUmJ4WIHowMl1AlCC6m1xA3UsBctHRbFkttik9pReYltrWzt7Ft1aap36_S2tKRzEN9qngJM1D7yd2s0Ok0kLeC54DxBvuqQVKe4dk6g_XC7ElV8w6JUQBN9xMLbnMQG49qvq2syugk-Ujj1M9VGsNr85HdgAFymODW3vI4w1hrz4rCDOU_uuDDGHoEvChnFVZ_tmO80IUKUCiWIIzkBn8k8mnmbnC0vCRMV-YT1J7DS-pznuqcEZhotJ3DnD3TwNJevVklo7QfZGL6hyIK-t5DNMBc3uujS1bZ9bLA3RMqXWgD9ZTmjcutw}
|
||||
jwks:
|
||||
url: https://nrlkjfznsavsbmweiyqu.supabase.co/auth/v1/.well-known/jwks.json
|
||||
|
||||
# Set up a consistent temporary directory location
|
||||
java.io.tmpdir: ${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
|
@ -40,8 +40,11 @@ dependencies {
|
||||
api 'org.springframework:spring-jdbc'
|
||||
api 'org.springframework:spring-webmvc'
|
||||
api 'org.springframework.session:spring-session-core'
|
||||
api "org.springframework.security:spring-security-core:$springSecuritySamlVersion"
|
||||
api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
|
||||
api "org.springframework.security:spring-security-core:$springSecurityVersion"
|
||||
api "org.springframework.security:spring-security-saml2-service-provider:$springSecurityVersion"
|
||||
api "org.springframework.security:spring-security-oauth2-resource-server:$springSecurityVersion"
|
||||
api "org.springframework.security:spring-security-oauth2-jose:$springSecurityVersion"
|
||||
api 'org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.6.8'
|
||||
api 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
api 'org.springframework.boot:spring-boot-starter-security'
|
||||
api 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
|
@ -0,0 +1,38 @@
|
||||
package stirling.software.proprietary.controller;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class MeController {
|
||||
|
||||
@GetMapping("/me")
|
||||
public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt, Authentication authentication) {
|
||||
List<String> authorities =
|
||||
authentication.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.toList();
|
||||
|
||||
return Map.of(
|
||||
"subject", jwt.getSubject(),
|
||||
"issuer", jwt.getIssuer() != null ? jwt.getIssuer().toString() : null,
|
||||
"audience", jwt.getAudience(),
|
||||
"issuedAt", toEpoch(jwt.getIssuedAt()),
|
||||
"expiresAt", toEpoch(jwt.getExpiresAt()),
|
||||
"authorities", authorities,
|
||||
"claims", jwt.getClaims() // full map of claims from the token
|
||||
);
|
||||
}
|
||||
|
||||
private Long toEpoch(Instant i) {
|
||||
return i == null ? null : i.getEpochSecond();
|
||||
}
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||
@ -53,12 +53,13 @@ public class CustomAuthenticationSuccessHandler
|
||||
}
|
||||
loginAttemptService.loginSucceeded(userName);
|
||||
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.WEB));
|
||||
jwtService.addToken(response, jwt);
|
||||
log.debug("JWT generated for user: {}", userName);
|
||||
if (true) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType",
|
||||
AuthenticationType.WEB));
|
||||
jwtService.addToken(response, jwt);
|
||||
log.debug("JWT generated for user: {}", userName);
|
||||
|
||||
getRedirectStrategy().sendRedirect(request, response, "/");
|
||||
} else {
|
||||
|
@ -7,9 +7,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProp
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||
import org.springframework.boot.jdbc.DatabaseDriver;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
import lombok.Getter;
|
||||
@ -58,18 +56,18 @@ public class DatabaseConfig {
|
||||
* @return a <code>DataSource</code> using the configuration settings in the settings.yml
|
||||
* @throws UnsupportedProviderException if the type of database selected is not supported
|
||||
*/
|
||||
@Bean
|
||||
@Qualifier("dataSource")
|
||||
@Primary
|
||||
public DataSource dataSource() throws UnsupportedProviderException {
|
||||
DataSourceBuilder<?> dataSourceBuilder = DataSourceBuilder.create();
|
||||
|
||||
if (!runningProOrHigher || !datasource.isEnableCustomDatabase()) {
|
||||
return useDefaultDataSource(dataSourceBuilder);
|
||||
}
|
||||
|
||||
return useCustomDataSource(dataSourceBuilder);
|
||||
}
|
||||
// @Bean
|
||||
// @Qualifier("dataSource")
|
||||
// @Primary
|
||||
// public DataSource dataSource() throws UnsupportedProviderException {
|
||||
// DataSourceBuilder<?> dataSourceBuilder = DataSourceBuilder.create();
|
||||
//
|
||||
// if (!runningProOrHigher || !datasource.isEnableCustomDatabase()) {
|
||||
// return useDefaultDataSource(dataSourceBuilder);
|
||||
// }
|
||||
//
|
||||
// return useCustomDataSource(dataSourceBuilder);
|
||||
// }
|
||||
|
||||
private DataSource useDefaultDataSource(DataSourceBuilder<?> dataSourceBuilder) {
|
||||
log.info("Using default H2 database");
|
||||
|
@ -18,6 +18,7 @@ import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||||
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
|
||||
@ -132,20 +133,24 @@ public class SecurityConfiguration {
|
||||
if (loginEnabledValue) {
|
||||
boolean v2Enabled = appConfig.v2Enabled();
|
||||
|
||||
if (v2Enabled) {
|
||||
http.addFilterBefore(
|
||||
jwtAuthenticationFilter(),
|
||||
UsernamePasswordAuthenticationFilter.class)
|
||||
.exceptionHandling(
|
||||
exceptionHandling ->
|
||||
exceptionHandling.authenticationEntryPoint(
|
||||
jwtAuthenticationEntryPoint));
|
||||
}
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class)
|
||||
.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
// if (v2Enabled) {
|
||||
http.oauth2ResourceServer(
|
||||
oauth2 ->
|
||||
oauth2.jwt(
|
||||
jwtConfigurer ->
|
||||
jwtConfigurer.jwkSetUri(
|
||||
"https://nrlkjfznsavsbmweiyqu.supabase.co/auth/v1/.well-known/jwks.json")));
|
||||
http.addFilterBefore(jwtAuthenticationFilter(), BearerTokenAuthenticationFilter.class)
|
||||
.exceptionHandling(
|
||||
exceptionHandling ->
|
||||
exceptionHandling.authenticationEntryPoint(
|
||||
jwtAuthenticationEntryPoint));
|
||||
// }
|
||||
if (!securityProperties.getCsrfDisabled()) {
|
||||
CookieCsrfTokenRepository cookieRepo =
|
||||
CookieCsrfTokenRepository.withHttpOnlyFalse();
|
||||
|
@ -111,7 +111,9 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) {
|
||||
response.sendRedirect(contextPath + "/login"); // redirect to the login page
|
||||
// response.sendRedirect(contextPath + "/login"); // redirect to the
|
||||
// login page
|
||||
filterChain.doFilter(request, response);
|
||||
} else {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.getWriter()
|
||||
|
@ -4,6 +4,9 @@ import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
@ -15,19 +18,27 @@ import lombok.ToString;
|
||||
@NoArgsConstructor
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
public class JwtVerificationKey implements Serializable {
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class JwtSigningKey implements Serializable {
|
||||
|
||||
@Serial private static final long serialVersionUID = 1L;
|
||||
|
||||
@ToString.Include private String keyId;
|
||||
@JsonAlias("kid")
|
||||
@ToString.Include
|
||||
private String keyId;
|
||||
|
||||
private String verifyingKey;
|
||||
@JsonAlias("n")
|
||||
private String key;
|
||||
|
||||
@JsonAlias("alg")
|
||||
private String algorithm;
|
||||
|
||||
@ToString.Include private LocalDateTime createdAt;
|
||||
|
||||
public JwtVerificationKey(String keyId, String verifyingKey) {
|
||||
public JwtSigningKey(String keyId, String key) {
|
||||
this.keyId = keyId;
|
||||
this.verifyingKey = verifyingKey;
|
||||
this.key = key;
|
||||
this.algorithm = "RS256";
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
@ -35,7 +35,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.JwtSigningKey;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
|
||||
@ -80,7 +80,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
@Override
|
||||
public String generateToken(String username, Map<String, Object> claims) {
|
||||
try {
|
||||
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
||||
JwtSigningKey activeKey = keyPersistenceService.getActiveKey();
|
||||
Optional<KeyPair> keyPairOpt = keyPersistenceService.getKeyPair(activeKey.getKeyId());
|
||||
|
||||
if (keyPairOpt.isEmpty()) {
|
||||
@ -159,7 +159,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
keyId);
|
||||
|
||||
if (keyId.equals(keyPersistenceService.getActiveKey().getKeyId())) {
|
||||
JwtVerificationKey verificationKey =
|
||||
JwtSigningKey verificationKey =
|
||||
keyPersistenceService.refreshActiveKeyPair();
|
||||
Optional<KeyPair> refreshedKeyPair =
|
||||
keyPersistenceService.getKeyPair(verificationKey.getKeyId());
|
||||
@ -171,7 +171,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
} else {
|
||||
// Try to use active key as fallback
|
||||
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
||||
JwtSigningKey activeKey = keyPersistenceService.getActiveKey();
|
||||
Optional<KeyPair> activeKeyPair =
|
||||
keyPersistenceService.getKeyPair(activeKey.getKeyId());
|
||||
if (activeKeyPair.isPresent()) {
|
||||
@ -214,9 +214,8 @@ public class JwtService implements JwtServiceInterface {
|
||||
private Claims tryAllKeys(String token) throws AuthenticationFailureException {
|
||||
// First try the active key
|
||||
try {
|
||||
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
||||
PublicKey publicKey =
|
||||
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
|
||||
JwtSigningKey activeKey = keyPersistenceService.getActiveKey();
|
||||
PublicKey publicKey = keyPersistenceService.decodePublicKey(activeKey.getKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
@ -228,15 +227,14 @@ public class JwtService implements JwtServiceInterface {
|
||||
log.debug("Active key failed, trying all available keys from cache");
|
||||
|
||||
// If active key fails, try all available keys from cache
|
||||
List<JwtVerificationKey> allKeys =
|
||||
List<JwtSigningKey> allKeys =
|
||||
keyPersistenceService.getKeysEligibleForCleanup(
|
||||
LocalDateTime.now().plusDays(1));
|
||||
|
||||
for (JwtVerificationKey verificationKey : allKeys) {
|
||||
for (JwtSigningKey verificationKey : allKeys) {
|
||||
try {
|
||||
PublicKey publicKey =
|
||||
keyPersistenceService.decodePublicKey(
|
||||
verificationKey.getVerifyingKey());
|
||||
keyPersistenceService.decodePublicKey(verificationKey.getKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
@ -310,7 +308,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
try {
|
||||
PublicKey signingKey =
|
||||
keyPersistenceService.decodePublicKey(
|
||||
keyPersistenceService.getActiveKey().getVerifyingKey());
|
||||
keyPersistenceService.getActiveKey().getKey());
|
||||
|
||||
String keyId =
|
||||
(String)
|
||||
|
@ -20,7 +20,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.JwtSigningKey;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@ -49,7 +49,7 @@ public class KeyPairCleanupService {
|
||||
LocalDateTime cutoffDate =
|
||||
LocalDateTime.now().minusDays(jwtProperties.getKeyRetentionDays());
|
||||
|
||||
List<JwtVerificationKey> eligibleKeys =
|
||||
List<JwtSigningKey> eligibleKeys =
|
||||
keyPersistenceService.getKeysEligibleForCleanup(cutoffDate);
|
||||
if (eligibleKeys.isEmpty()) {
|
||||
return;
|
||||
@ -60,7 +60,7 @@ public class KeyPairCleanupService {
|
||||
keyPersistenceService.refreshActiveKeyPair();
|
||||
}
|
||||
|
||||
private void removeKeys(List<JwtVerificationKey> keys) {
|
||||
private void removeKeys(List<JwtSigningKey> keys) {
|
||||
keys.forEach(
|
||||
key -> {
|
||||
try {
|
||||
|
@ -34,7 +34,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.JwtSigningKey;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@ -46,7 +46,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
private final CacheManager cacheManager;
|
||||
private final Cache verifyingKeyCache;
|
||||
|
||||
private volatile JwtVerificationKey activeKey;
|
||||
private volatile JwtSigningKey activeKey;
|
||||
|
||||
@Autowired
|
||||
public KeyPersistenceService(
|
||||
@ -77,15 +77,15 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
private JwtVerificationKey generateAndStoreKeypair() {
|
||||
JwtVerificationKey verifyingKey = null;
|
||||
private JwtSigningKey generateAndStoreKeypair() {
|
||||
JwtSigningKey verifyingKey = null;
|
||||
|
||||
try {
|
||||
KeyPair keyPair = generateRSAKeypair();
|
||||
String keyId = generateKeyId();
|
||||
|
||||
storePrivateKey(keyId, keyPair.getPrivate());
|
||||
verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic()));
|
||||
verifyingKey = new JwtSigningKey(keyId, encodePublicKey(keyPair.getPublic()));
|
||||
verifyingKeyCache.put(keyId, verifyingKey);
|
||||
activeKey = verifyingKey;
|
||||
} catch (IOException e) {
|
||||
@ -96,7 +96,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
|
||||
@Override
|
||||
public JwtVerificationKey getActiveKey() {
|
||||
public JwtSigningKey getActiveKey() {
|
||||
if (activeKey == null) {
|
||||
return generateAndStoreKeypair();
|
||||
}
|
||||
@ -110,8 +110,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
|
||||
try {
|
||||
JwtVerificationKey verifyingKey =
|
||||
verifyingKeyCache.get(keyId, JwtVerificationKey.class);
|
||||
JwtSigningKey verifyingKey = verifyingKeyCache.get(keyId, JwtSigningKey.class);
|
||||
|
||||
if (verifyingKey == null) {
|
||||
log.warn("No signing key found in database for keyId: {}", keyId);
|
||||
@ -119,7 +118,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
|
||||
PrivateKey privateKey = loadPrivateKey(keyId);
|
||||
PublicKey publicKey = decodePublicKey(verifyingKey.getVerifyingKey());
|
||||
PublicKey publicKey = decodePublicKey(verifyingKey.getKey());
|
||||
|
||||
return Optional.of(new KeyPair(publicKey, privateKey));
|
||||
} catch (Exception e) {
|
||||
@ -134,7 +133,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
|
||||
@Override
|
||||
public JwtVerificationKey refreshActiveKeyPair() {
|
||||
public JwtSigningKey refreshActiveKeyPair() {
|
||||
return generateAndStoreKeypair();
|
||||
}
|
||||
|
||||
@ -148,7 +147,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<JwtVerificationKey> getKeysEligibleForCleanup(LocalDateTime cutoffDate) {
|
||||
public List<JwtSigningKey> getKeysEligibleForCleanup(LocalDateTime cutoffDate) {
|
||||
CaffeineCache caffeineCache = (CaffeineCache) verifyingKeyCache;
|
||||
com.github.benmanes.caffeine.cache.Cache<Object, Object> nativeCache =
|
||||
caffeineCache.getNativeCache();
|
||||
@ -159,8 +158,8 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
nativeCache.asMap().size());
|
||||
|
||||
return nativeCache.asMap().values().stream()
|
||||
.filter(value -> value instanceof JwtVerificationKey)
|
||||
.map(value -> (JwtVerificationKey) value)
|
||||
.filter(value -> value instanceof JwtSigningKey)
|
||||
.map(value -> (JwtSigningKey) value)
|
||||
.filter(
|
||||
key -> {
|
||||
boolean eligible = key.getCreatedAt().isBefore(cutoffDate);
|
||||
|
@ -8,19 +8,19 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.JwtSigningKey;
|
||||
|
||||
public interface KeyPersistenceServiceInterface {
|
||||
|
||||
JwtVerificationKey getActiveKey();
|
||||
JwtSigningKey getActiveKey();
|
||||
|
||||
Optional<KeyPair> getKeyPair(String keyId);
|
||||
|
||||
boolean isKeystoreEnabled();
|
||||
|
||||
JwtVerificationKey refreshActiveKeyPair();
|
||||
JwtSigningKey refreshActiveKeyPair();
|
||||
|
||||
List<JwtVerificationKey> getKeysEligibleForCleanup(LocalDateTime cutoffDate);
|
||||
List<JwtSigningKey> getKeysEligibleForCleanup(LocalDateTime cutoffDate);
|
||||
|
||||
void removeKey(String keyId);
|
||||
|
||||
|
@ -1,20 +1,13 @@
|
||||
package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DatabaseConfigTest {
|
||||
@ -28,59 +21,60 @@ class DatabaseConfigTest {
|
||||
databaseConfig = new DatabaseConfig(datasource, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDataSource_whenRunningEEIsFalse() throws UnsupportedProviderException {
|
||||
databaseConfig = new DatabaseConfig(datasource, false);
|
||||
|
||||
var result = databaseConfig.dataSource();
|
||||
|
||||
assertInstanceOf(DataSource.class, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDefaultConfigurationForDataSource() throws UnsupportedProviderException {
|
||||
when(datasource.isEnableCustomDatabase()).thenReturn(false);
|
||||
|
||||
var result = databaseConfig.dataSource();
|
||||
|
||||
assertInstanceOf(DataSource.class, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCustomUrlForDataSource() throws UnsupportedProviderException {
|
||||
when(datasource.isEnableCustomDatabase()).thenReturn(true);
|
||||
when(datasource.getCustomDatabaseUrl()).thenReturn("jdbc:postgresql://mockUrl");
|
||||
when(datasource.getUsername()).thenReturn("test");
|
||||
when(datasource.getPassword()).thenReturn("pass");
|
||||
|
||||
var result = databaseConfig.dataSource();
|
||||
|
||||
assertInstanceOf(DataSource.class, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCustomConfigurationForDataSource() throws UnsupportedProviderException {
|
||||
when(datasource.isEnableCustomDatabase()).thenReturn(true);
|
||||
when(datasource.getCustomDatabaseUrl()).thenReturn("");
|
||||
when(datasource.getType()).thenReturn("postgresql");
|
||||
when(datasource.getHostName()).thenReturn("test");
|
||||
when(datasource.getPort()).thenReturn(1234);
|
||||
when(datasource.getName()).thenReturn("test_db");
|
||||
when(datasource.getUsername()).thenReturn("test");
|
||||
when(datasource.getPassword()).thenReturn("pass");
|
||||
|
||||
var result = databaseConfig.dataSource();
|
||||
|
||||
assertInstanceOf(DataSource.class, result);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "Exception thrown when the DB type [{arguments}] is not supported")
|
||||
@ValueSource(strings = {"oracle", "mysql", "mongoDb"})
|
||||
void exceptionThrown_whenDBTypeIsUnsupported(String datasourceType) {
|
||||
when(datasource.isEnableCustomDatabase()).thenReturn(true);
|
||||
when(datasource.getCustomDatabaseUrl()).thenReturn("");
|
||||
when(datasource.getType()).thenReturn(datasourceType);
|
||||
|
||||
assertThrows(UnsupportedProviderException.class, () -> databaseConfig.dataSource());
|
||||
}
|
||||
// @Test
|
||||
// void testDataSource_whenRunningEEIsFalse() throws UnsupportedProviderException {
|
||||
// databaseConfig = new DatabaseConfig(datasource, false);
|
||||
//
|
||||
// var result = databaseConfig.dataSource();
|
||||
//
|
||||
// assertInstanceOf(DataSource.class, result);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void testDefaultConfigurationForDataSource() throws UnsupportedProviderException {
|
||||
// when(datasource.isEnableCustomDatabase()).thenReturn(false);
|
||||
//
|
||||
// var result = databaseConfig.dataSource();
|
||||
//
|
||||
// assertInstanceOf(DataSource.class, result);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void testCustomUrlForDataSource() throws UnsupportedProviderException {
|
||||
// when(datasource.isEnableCustomDatabase()).thenReturn(true);
|
||||
// when(datasource.getCustomDatabaseUrl()).thenReturn("jdbc:postgresql://mockUrl");
|
||||
// when(datasource.getUsername()).thenReturn("test");
|
||||
// when(datasource.getPassword()).thenReturn("pass");
|
||||
//
|
||||
// var result = databaseConfig.dataSource();
|
||||
//
|
||||
// assertInstanceOf(DataSource.class, result);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// void testCustomConfigurationForDataSource() throws UnsupportedProviderException {
|
||||
// when(datasource.isEnableCustomDatabase()).thenReturn(true);
|
||||
// when(datasource.getCustomDatabaseUrl()).thenReturn("");
|
||||
// when(datasource.getType()).thenReturn("postgresql");
|
||||
// when(datasource.getHostName()).thenReturn("test");
|
||||
// when(datasource.getPort()).thenReturn(1234);
|
||||
// when(datasource.getName()).thenReturn("test_db");
|
||||
// when(datasource.getUsername()).thenReturn("test");
|
||||
// when(datasource.getPassword()).thenReturn("pass");
|
||||
//
|
||||
// var result = databaseConfig.dataSource();
|
||||
//
|
||||
// assertInstanceOf(DataSource.class, result);
|
||||
// }
|
||||
//
|
||||
// @ParameterizedTest(name = "Exception thrown when the DB type [{arguments}] is not
|
||||
// supported")
|
||||
// @ValueSource(strings = {"oracle", "mysql", "mongoDb"})
|
||||
// void exceptionThrown_whenDBTypeIsUnsupported(String datasourceType) {
|
||||
// when(datasource.isEnableCustomDatabase()).thenReturn(true);
|
||||
// when(datasource.getCustomDatabaseUrl()).thenReturn("");
|
||||
// when(datasource.getType()).thenReturn(datasourceType);
|
||||
//
|
||||
// assertThrows(UnsupportedProviderException.class, () -> databaseConfig.dataSource());
|
||||
// }
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.JwtSigningKey;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
|
||||
@ -56,7 +56,7 @@ class JwtServiceTest {
|
||||
|
||||
private JwtService jwtService;
|
||||
private KeyPair testKeyPair;
|
||||
private JwtVerificationKey testVerificationKey;
|
||||
private JwtSigningKey testVerificationKey;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws NoSuchAlgorithmException {
|
||||
@ -68,7 +68,7 @@ class JwtServiceTest {
|
||||
// Create test verification key
|
||||
String encodedPublicKey =
|
||||
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
|
||||
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
|
||||
testVerificationKey = new JwtSigningKey("test-key-id", encodedPublicKey);
|
||||
|
||||
jwtService = new JwtService(true, keystoreService);
|
||||
}
|
||||
@ -79,7 +79,7 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
@ -100,7 +100,7 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
@ -120,7 +120,7 @@ class JwtServiceTest {
|
||||
void testValidateTokenSuccess() throws Exception {
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn("testuser");
|
||||
@ -133,7 +133,7 @@ class JwtServiceTest {
|
||||
@Test
|
||||
void testValidateTokenWithInvalidToken() throws Exception {
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
|
||||
assertThrows(
|
||||
@ -146,7 +146,7 @@ class JwtServiceTest {
|
||||
@Test
|
||||
void testValidateTokenWithMalformedToken() throws Exception {
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
|
||||
AuthenticationFailureException exception =
|
||||
@ -162,7 +162,7 @@ class JwtServiceTest {
|
||||
@Test
|
||||
void testValidateTokenWithEmptyToken() throws Exception {
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
|
||||
AuthenticationFailureException exception =
|
||||
@ -185,7 +185,7 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(user);
|
||||
when(user.getUsername()).thenReturn(username);
|
||||
@ -198,7 +198,7 @@ class JwtServiceTest {
|
||||
@Test
|
||||
void testExtractUsernameWithInvalidToken() throws Exception {
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
|
||||
assertThrows(
|
||||
@ -213,7 +213,7 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
@ -230,7 +230,7 @@ class JwtServiceTest {
|
||||
@Test
|
||||
void testExtractClaimsWithInvalidToken() throws Exception {
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
|
||||
assertThrows(
|
||||
@ -321,7 +321,7 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
@ -347,7 +347,7 @@ class JwtServiceTest {
|
||||
// First, generate a token successfully
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
@ -356,8 +356,8 @@ class JwtServiceTest {
|
||||
|
||||
// Now mock the scenario for validation - key not found, but fallback works
|
||||
// Create a fallback key pair that can be used
|
||||
JwtVerificationKey fallbackKey =
|
||||
new JwtVerificationKey(
|
||||
JwtSigningKey fallbackKey =
|
||||
new JwtSigningKey(
|
||||
"fallback-key",
|
||||
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()));
|
||||
|
||||
|
@ -31,7 +31,7 @@ import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.JwtSigningKey;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class KeyPersistenceServiceInterfaceTest {
|
||||
@ -87,11 +87,11 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
keyPersistenceService.initializeKeystore();
|
||||
|
||||
JwtVerificationKey result = keyPersistenceService.getActiveKey();
|
||||
JwtSigningKey result = keyPersistenceService.getActiveKey();
|
||||
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.getKeyId());
|
||||
assertNotNull(result.getVerifyingKey());
|
||||
assertNotNull(result.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,7 +103,7 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
String privateKeyBase64 =
|
||||
Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded());
|
||||
|
||||
JwtVerificationKey existingKey = new JwtVerificationKey(keyId, publicKeyBase64);
|
||||
JwtSigningKey existingKey = new JwtSigningKey(keyId, publicKeyBase64);
|
||||
|
||||
Path keyFile = tempDir.resolve(keyId + ".key");
|
||||
Files.writeString(keyFile, privateKeyBase64);
|
||||
@ -116,7 +116,7 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
keyPersistenceService.initializeKeystore();
|
||||
|
||||
JwtVerificationKey result = keyPersistenceService.getActiveKey();
|
||||
JwtSigningKey result = keyPersistenceService.getActiveKey();
|
||||
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.getKeyId());
|
||||
@ -131,7 +131,7 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
String privateKeyBase64 =
|
||||
Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded());
|
||||
|
||||
JwtVerificationKey signingKey = new JwtVerificationKey(keyId, publicKeyBase64);
|
||||
JwtSigningKey signingKey = new JwtSigningKey(keyId, publicKeyBase64);
|
||||
|
||||
Path keyFile = tempDir.resolve(keyId + ".key");
|
||||
Files.writeString(keyFile, privateKeyBase64);
|
||||
@ -213,7 +213,7 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
String publicKeyBase64 =
|
||||
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
|
||||
|
||||
JwtVerificationKey existingKey = new JwtVerificationKey(keyId, publicKeyBase64);
|
||||
JwtSigningKey existingKey = new JwtSigningKey(keyId, publicKeyBase64);
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> mockedStatic =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
@ -223,10 +223,10 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
keyPersistenceService.initializeKeystore();
|
||||
|
||||
JwtVerificationKey result = keyPersistenceService.getActiveKey();
|
||||
JwtSigningKey result = keyPersistenceService.getActiveKey();
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.getKeyId());
|
||||
assertNotNull(result.getVerifyingKey());
|
||||
assertNotNull(result.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ ext {
|
||||
imageioVersion = "3.12.0"
|
||||
lombokVersion = "1.18.38"
|
||||
bouncycastleVersion = "1.81"
|
||||
springSecuritySamlVersion = "6.5.2"
|
||||
springSecurityVersion = "6.5.2"
|
||||
openSamlVersion = "4.3.2"
|
||||
commonmarkVersion = "0.25.1"
|
||||
googleJavaFormatVersion = "1.28.0"
|
||||
|
Loading…
x
Reference in New Issue
Block a user