Sesje sprawdzane w bazie, e-mail ogloszeniodawcy niepubliczny, zgody z formularza
Deploy Polska Lokalnie / deploy (push) Successful in 12s

- JwtAuthFilter bierze role i stan konta z bazy: blokada, usuniecie konta
  i odebranie roli admina dzialaja od razu. Zmiana i reset hasla koncza
  dotychczasowe sesje (sessionsValidFrom); zmiana hasla zwraca nowy token.
- Publiczne odpowiedzi ogloszen bez ownerEmail/contactEmail; inne oferty
  sprzedajacego z GET /api/listings/{id}/seller-listings.
- Publiczny formularz reklamowy nie zmienia zgod ani nazwy istniejacego leada.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 13:56:36 +02:00
parent 3a555e3b24
commit ac7a3a4efb
16 changed files with 256 additions and 49 deletions
@@ -166,6 +166,8 @@ public class AdminController {
String temporaryPassword = generateTemporaryPassword();
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
// Reset przez administracje (np. przy przejetym koncie) konczy wszystkie sesje uzytkownika.
user.revokeSessions();
userRepository.save(user);
notificationService.create(user.getEmail(), "system", "lock",
"Hasło zostało zresetowane",
@@ -112,10 +112,10 @@ public class AuthController {
// --- Operacje na wlasnym koncie z /konto/ustawienia ---
// Zmiana hasla konczy wszystkie dotychczasowe sesje - odpowiedz niesie nowy token dla biezacej.
@PostMapping("/change-password")
public ResponseEntity<Void> changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
return ResponseEntity.noContent().build();
public AuthResponse changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
return authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
}
/**
@@ -191,6 +191,8 @@ public class AuthService {
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
user.setPasswordHash(passwordEncoder.encode(newPassword));
// Reset hasla konczy wszystkie dotychczasowe sesje - takze te przejete przez intruza.
user.revokeSessions();
// Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne.
user.setVerified(true);
userRepository.save(user);
@@ -330,7 +332,7 @@ public class AuthService {
* wiec przejeta sesja nie pozwala przejac konta na stale.
*/
@Transactional
public void changePassword(String email, String currentPassword, String newPassword) {
public AuthResponse changePassword(String email, String currentPassword, String newPassword) {
AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
@@ -357,6 +359,8 @@ public class AuthService {
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setPasswordChangedAt(Instant.now());
// Inne sesje (np. przejete) koncza sie od razu; biezaca dostaje nowy token w odpowiedzi.
user.revokeSessions();
userRepository.save(user);
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
@@ -366,6 +370,7 @@ public class AuthService {
authTokenRepository.save(old);
}
});
return buildAuthResponse(user);
}
/**
@@ -6,6 +6,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -14,14 +15,18 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserRepository userRepository;
public JwtAuthFilter(JwtService jwtService) {
public JwtAuthFilter(JwtService jwtService, UserRepository userRepository) {
this.jwtService = jwtService;
this.userRepository = userRepository;
}
@Override
@@ -34,12 +39,15 @@ public class JwtAuthFilter extends OncePerRequestFilter {
if (token != null) {
try {
Claims claims = jwtService.parse(token);
String email = claims.getSubject();
String role = claims.get("role", String.class);
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
var authentication = new UsernamePasswordAuthenticationToken(email, null, authorities);
// Stan konta bierzemy z bazy, a nie z tokenu: zablokowanie, usuniecie konta, odebranie
// roli administratora i zmiana hasla dzialaja od razu, a nie dopiero po wygasnieciu tokenu.
AppUser user = userRepository.findByEmailIgnoreCase(claims.getSubject()).orElse(null);
if (user != null && isSessionValid(user, claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant())) {
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
var authentication = new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
// Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione.
SecurityContextHolder.clearContext();
@@ -48,6 +56,14 @@ public class JwtAuthFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
static boolean isSessionValid(AppUser user, Instant issuedAt) {
if (user.isBlocked() || user.getRole() == null) {
return false;
}
Instant validFrom = user.getSessionsValidFrom();
return validFrom == null || (issuedAt != null && !issuedAt.isBefore(validFrom));
}
// Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token.
private String resolveToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
@@ -231,7 +231,11 @@ public class LeadService {
lead.setPhone(phone);
}
}
// Formularz jest publiczny i nie potwierdza, ze wysylajacy jest wlascicielem adresu - wpisanie
// cudzego e-maila nie moze nadpisac nazwy istniejacego kontaktu.
if (lead.getId() == null || lead.getName() == null) {
lead.setName(trimToNull(request.fullName()));
}
List<String> tags = new ArrayList<>(lead.getTags());
tags.add(ADVERTISING_TAG);
@@ -239,9 +243,12 @@ public class LeadService {
lead.setTags(cleanTags(tags));
// Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc
// telefoniczny. Raz cofnietej zgody nie przywracamy tutaj po cichu.
// telefoniczny. Tylko dla NOWEGO kontaktu: formularz jest anonimowy, wiec wpisujac cudzy
// e-mail lub telefon daloby sie komus przywrocic cofnieta zgode (albo ja odebrac).
if (lead.getId() == null) {
lead.setEmailConsent(consent);
lead.setSmsConsent(consent);
}
lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone));
leadRepository.save(lead);
}
@@ -40,6 +40,12 @@ public class ListingController {
return listingService.search(city, offerType, propertyType, directOnly);
}
// Inne oferty sprzedajacego - publiczne jak same ogloszenia (GET /api/listings/**).
@GetMapping("/{id}/seller-listings")
public List<ListingResponse> sellerListings(@PathVariable Long id) {
return listingService.sellerListings(id);
}
@GetMapping("/mine")
public List<ListingResponse> mine(Authentication authentication) {
return listingService.findMine(authentication.getName());
@@ -72,7 +72,7 @@ public record ListingDetailResponse(
private static final double METERS_PER_DEGREE = 111_320d;
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
return from(listing, ownerId, ownerAvatar, null, null, null, null, false);
return from(listing, ownerId, ownerAvatar, null, null, null, null, false, false);
}
/**
@@ -80,10 +80,14 @@ public record ListingDetailResponse(
* wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa
* przesuwane w obrebie obszaru. Przesuniecie liczymy z identyfikatora ogloszenia, wiec dla danej
* oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu.
*
* Gdy {@code publicView} jest true (oglada ktos inny niz wlasciciel i administracja), znika adres
* e-mail konta wlasciciela - zarowno ownerEmail, jak i contactEmail, ktory formularz wypelnia
* adresem konta. E-mail jest loginem do serwisu, a publiczny kontakt to telefon i wiadomosci.
*/
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar,
String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt,
boolean masked) {
boolean masked, boolean publicView) {
String street = masked ? null : listing.getStreet();
String building = masked ? null : listing.getBuilding();
String address = masked ? approximateAddress(listing) : listing.getAddress();
@@ -111,7 +115,7 @@ public record ListingDetailResponse(
listing.getOwnership(),
listing.getContactName(),
listing.getContactPhone(),
listing.getContactEmail(),
publicView ? null : listing.getContactEmail(),
listing.getRentExtra(),
listing.getAvailableFrom(),
listing.getFurnishing(),
@@ -126,7 +130,7 @@ public record ListingDetailResponse(
List.copyOf(listing.getAmenities()),
List.copyOf(listing.getPhotos()),
listing.getVirtualTourUrl(),
listing.getOwnerEmail(),
publicView ? null : listing.getOwnerEmail(),
ownerId,
ownerAvatar,
ownerAccountType,
@@ -61,7 +61,7 @@ public class ListingFavoriteController {
// Ogloszenie skasowane albo odrzucone po zapisaniu po prostu znika z listy.
return listingRepository.findAllById(listingIds).stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
.map(ListingResponse::publicFrom)
.toList();
}
@@ -30,15 +30,20 @@ public record ListingResponse(
Instant promotedUntil,
Instant createdAt
) {
/** Pelna wersja - dla panelu wlasciciela ("moje ogloszenia") i administracji. */
public static ListingResponse from(PropertyListing listing) {
return from(listing, false);
return build(listing, false, listing.getOwnerEmail());
}
/**
* @param masked pokaz sama okolice zamiast adresu - dla ogloszen z ukryta dokladna lokalizacja
* ogladanych spoza panelu wlasciciela i administracji.
* Wersja publiczna (wyszukiwarka, ulubione, inne oferty sprzedajacego): sama okolica zamiast
* adresu dla ogloszen z ukryta lokalizacja i bez e-maila wlasciciela - to login do serwisu.
*/
public static ListingResponse from(PropertyListing listing, boolean masked) {
public static ListingResponse publicFrom(PropertyListing listing) {
return build(listing, listing.isApproximateLocation(), null);
}
private static ListingResponse build(PropertyListing listing, boolean masked, String ownerEmail) {
String district = listing.getDistrict();
String address = masked
? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity())
@@ -60,7 +65,7 @@ public record ListingResponse(
listing.getMarket(),
listing.getYearBuilt(),
listing.getCoverPhoto(),
listing.getOwnerEmail(),
ownerEmail,
listing.getStatus(),
listing.getViewsCount(),
listing.getPromotedUntil(),
@@ -52,21 +52,22 @@ public class ListingService {
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
*/
private ListingDetailResponse detailOf(PropertyListing listing) {
return detailOf(listing, false);
return detailOf(listing, false, false);
}
/**
* @param masked ukryj dokladny adres (ulica, numer, wspolrzedne) - dla ogloszen, ktorych
* wlasciciel wybral publikowanie samej przyblizonej lokalizacji.
* @param publicView odpowiedz dla kogos innego niz wlasciciel i administracja - bez adresu e-mail konta.
*/
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) {
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked, boolean publicView) {
AppUser owner = ownerOf(listing);
if (owner == null) {
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked);
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked, publicView);
}
String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name();
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()),
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked);
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked, publicView);
}
/**
@@ -97,7 +98,7 @@ public class ListingService {
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
.filter(listing -> !directOnly || isDirectOffer(listing))
.sorted(promotedFirst())
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
.map(ListingResponse::publicFrom)
.toList();
}
@@ -134,8 +135,29 @@ public class ListingService {
listing = listingRepository.save(listing);
}
// Wlasciciel i administracja widza pelny adres - reszta tylko przyblizona lokalizacje.
return detailOf(listing, listing.isApproximateLocation() && !isOwner && !viewerIsAdmin);
// Wlasciciel i administracja widza pelny adres i e-mail konta. Reszta - tylko przyblizona
// lokalizacje i bez e-maila (to login do serwisu; kontakt idzie przez wiadomosci i telefon).
boolean publicView = !isOwner && !viewerIsAdmin;
return detailOf(listing, listing.isApproximateLocation() && publicView, publicView);
}
/**
* Inne opublikowane oferty wlasciciela danego ogloszenia. Porownanie po e-mailu odbywa sie
* na serwerze, zeby adres wlasciciela nie musial trafiac do przegladarki.
*/
public List<ListingResponse> sellerListings(Long listingId) {
PropertyListing listing = listingRepository.findById(listingId).orElse(null);
if (listing == null || listing.getStatus() != ListingStatus.APPROVED
|| listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) {
return List.of();
}
return listingRepository
.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(listing.getOwnerEmail(), ListingStatus.APPROVED)
.stream()
.filter(other -> !other.getId().equals(listingId))
.limit(6)
.map(ListingResponse::publicFrom)
.toList();
}
public List<ListingResponse> findMine(String ownerEmail) {
@@ -95,6 +95,10 @@ public class AppUser {
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
private Instant passwordChangedAt;
// Tokeny sesji wydane przed tym momentem sa odrzucane - ustawiane przy zmianie i resecie hasla,
// zeby przejeta sesja nie przetrwala zmiany hasla. null = wszystkie niewygasle tokeny wazne.
private Instant sessionsValidFrom;
// Ostatnia aktywnosc uzytkownika (logowanie oraz kazde wejscie do aplikacji odswiezajace /auth/me).
// Na karcie sprzedajacego pokazujemy z tego "ostatnio aktywny/a"; null = jeszcze nie odnotowano.
private Instant lastActiveAt;
@@ -261,6 +265,15 @@ public class AppUser {
this.passwordChangedAt = passwordChangedAt;
}
public Instant getSessionsValidFrom() {
return sessionsValidFrom;
}
/** Uniewaznia wszystkie dotychczasowe sesje. JWT ma dokladnosc do sekundy - stad obciecie. */
public void revokeSessions() {
this.sessionsValidFrom = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
}
public Instant getLastActiveAt() {
return lastActiveAt;
}
@@ -0,0 +1,42 @@
package pl.polskalokalnie.auth;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.Role;
class JwtAuthFilterTest {
private AppUser user() {
AppUser user = new AppUser();
user.setEmail("user@example.com");
user.setRole(Role.USER);
return user;
}
@Test
void activeUserWithFreshTokenIsAccepted() {
assertThat(JwtAuthFilter.isSessionValid(user(), Instant.now())).isTrue();
}
@Test
void blockedUserLosesAccessImmediately() {
// Regresja: blokada dzialala tylko przy logowaniu - wydany token dzialal jeszcze do 24 h.
AppUser user = user();
user.setBlocked(true);
assertThat(JwtAuthFilter.isSessionValid(user, Instant.now())).isFalse();
}
@Test
void tokenIssuedBeforePasswordChangeIsRejected() {
AppUser user = user();
Instant oldToken = Instant.now().minusSeconds(60);
user.revokeSessions();
assertThat(JwtAuthFilter.isSessionValid(user, oldToken)).isFalse();
// Token wydany w tej samej sekundzie co zmiana (nowa sesja z odpowiedzi) jest wazny.
assertThat(JwtAuthFilter.isSessionValid(user, user.getSessionsValidFrom())).isTrue();
}
}
@@ -0,0 +1,54 @@
package pl.polskalokalnie.lead;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
class AdvertisingEnquiryConsentTest {
private final LeadRepository repository = mock(LeadRepository.class);
private final LeadService service = new LeadService(repository);
private AdvertisingEnquiryRequest enquiry(String name) {
return new AdvertisingEnquiryRequest(name, "Firma", "ofiara@example.com", "500600700",
"Remonty", null, true);
}
@Test
void anonymousFormDoesNotRestoreWithdrawnConsentOfExistingContact() {
// Regresja: wpisanie cudzego e-maila w publicznym formularzu przywracalo mu zgody marketingowe.
Lead existing = new Lead();
ReflectionTestUtils.setField(existing, "id", 5L);
existing.setEmail("ofiara@example.com");
existing.setName("Prawdziwe Nazwisko");
existing.setEmailConsent(false);
existing.setSmsConsent(false);
when(repository.findFirstByEmailIgnoreCase("ofiara@example.com")).thenReturn(Optional.of(existing));
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.registerAdvertisingEnquiry(enquiry("<a href=https://evil>Kliknij</a>"));
assertThat(existing.isEmailConsent()).isFalse();
assertThat(existing.isSmsConsent()).isFalse();
assertThat(existing.getName()).isEqualTo("Prawdziwe Nazwisko");
}
@Test
void newContactGetsConsentFromForm() {
when(repository.findFirstByEmailIgnoreCase(any())).thenReturn(Optional.empty());
when(repository.findFirstByPhone(any())).thenReturn(Optional.empty());
Lead[] saved = new Lead[1];
when(repository.save(any())).thenAnswer(inv -> saved[0] = inv.getArgument(0));
service.registerAdvertisingEnquiry(enquiry("Jan Kowalski"));
assertThat(saved[0].isEmailConsent()).isTrue();
assertThat(saved[0].isSmsConsent()).isTrue();
assertThat(saved[0].getName()).isEqualTo("Jan Kowalski");
}
}
@@ -0,0 +1,35 @@
package pl.polskalokalnie.listing;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class ListingPrivacyTest {
private PropertyListing listing() {
PropertyListing listing = new PropertyListing();
listing.setOwnerEmail("wlasciciel@example.com");
listing.setContactEmail("wlasciciel@example.com");
listing.setStatus(ListingStatus.APPROVED);
return listing;
}
@Test
void publicListDoesNotExposeOwnerEmail() {
// Regresja: anonimowe GET /api/listings zwracalo e-mail (login) kazdego ogloszeniodawcy.
assertThat(ListingResponse.publicFrom(listing()).ownerEmail()).isNull();
assertThat(ListingResponse.from(listing()).ownerEmail()).isEqualTo("wlasciciel@example.com");
}
@Test
void publicDetailHidesAccountEmailButOwnerSeesIt() {
ListingDetailResponse publicView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, true);
assertThat(publicView.ownerEmail()).isNull();
assertThat(publicView.contactEmail()).isNull();
assertThat(publicView.ownerId()).isEqualTo(7L);
ListingDetailResponse ownerView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, false);
assertThat(ownerView.ownerEmail()).isEqualTo("wlasciciel@example.com");
assertThat(ownerView.contactEmail()).isEqualTo("wlasciciel@example.com");
}
}
+9 -14
View File
@@ -27125,25 +27125,20 @@ function ListingDetailPage({
};
}, [listingId]);
// Inne oferty tego samego sprzedajacego (po ownerEmail) - minimalistyczna lista na dole strony.
// Inne oferty tego samego sprzedajacego - minimalistyczna lista na dole strony. Dopasowanie po
// wlascicielu robi serwer, bo e-mail wlasciciela nie jest publiczny.
useEffect(() => {
const owner = listing?.ownerEmail?.trim().toLowerCase();
if (!owner) {
const currentId = listing?.id;
if (currentId == null) {
setSellerListings([]);
return;
}
let cancelled = false;
apiFetch<ApiListingSummary[]>('/listings')
.then((all) => {
if (cancelled) {
return;
apiFetch<ApiListingSummary[]>(`/listings/${currentId}/seller-listings`)
.then((items) => {
if (!cancelled) {
setSellerListings(items);
}
setSellerListings(
all
.filter((item) => item.id !== listing?.id && item.ownerEmail
&& item.ownerEmail.trim().toLowerCase() === owner)
.slice(0, 6),
);
})
.catch(() => {
if (!cancelled) {
@@ -27153,7 +27148,7 @@ function ListingDetailPage({
return () => {
cancelled = true;
};
}, [listing?.ownerEmail, listing?.id]);
}, [listing?.id]);
useEffect(() => {
if (!listing) {
+4 -3
View File
@@ -300,12 +300,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return updated;
}, []);
// Zmiana hasla konczy wszystkie dotychczasowe sesje - serwer oddaje nowy token dla tej.
const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
await apiFetch<void>('/auth/change-password', {
applyAuth(await apiFetch<AuthResponse>('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
});
}, []);
}));
}, [applyAuth]);
// Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Rzuca bledem, gdy haslo jest zle.
const verifyPassword = useCallback(async (password: string) => {