API + kosmetyka
Deploy PROD / deploy (push) Successful in 25s

This commit is contained in:
2026-09-02 11:55:59 +02:00
parent 7b4d367a06
commit 9bd59d60bb
26 changed files with 1818 additions and 159 deletions
@@ -0,0 +1,78 @@
package com.softspm.webinar.apikey;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Zarzadzanie kluczami API. Dostep wylacznie dla roli ADMIN, wymuszony regula
* w SecurityConfig na sciezce /admin/api-keys/**.
*/
@RestController
@RequestMapping("/admin/api-keys")
@Tag(name = "Klucze API (admin)", description = "Zarzadzanie kluczami do publicznego API")
public class ApiKeyAdminController {
private final ApiKeyService service;
public ApiKeyAdminController(ApiKeyService service) {
this.service = service;
}
@GetMapping
@Operation(summary = "Lista kluczy API. Bez samego klucza, tylko prefiks.")
public List<ApiKeyView> list() {
return service.list();
}
@PostMapping
@Operation(summary = "Utworzenie klucza. Surowy klucz zwracany jeden raz.")
public ApiKeyCreated create(@Valid @RequestBody CreateRequest request) {
return service.create(request.name());
}
@PostMapping("/{id}/regenerate")
@Operation(summary = "Wygenerowanie nowego klucza dla tego odbiorcy. Nowy klucz zwracany raz, stary przestaje dzialac.")
public ApiKeyCreated regenerate(@PathVariable Long id) {
return service.regenerate(id);
}
@PostMapping("/{id}/activate")
@Operation(summary = "Wlaczenie klucza")
public ApiKeyView activate(@PathVariable Long id) {
return service.setActive(id, true);
}
@PostMapping("/{id}/deactivate")
@Operation(summary = "Wylaczenie klucza. Odbiera dostep natychmiast.")
public ApiKeyView deactivate(@PathVariable Long id) {
return service.setActive(id, false);
}
@DeleteMapping("/{id}")
@Operation(summary = "Skasowanie klucza na trwale")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
service.delete(id);
}
/** Zadanie utworzenia klucza: nazwa odbiorcy. */
public record CreateRequest(
@NotBlank(message = "Nazwa klucza jest wymagana")
@Size(max = 120, message = "Nazwa klucza jest zbyt długa")
String name) {
}
}
@@ -0,0 +1,11 @@
package com.softspm.webinar.apikey;
/**
* Odpowiedz na utworzenie klucza: widok plus SUROWY klucz.
*
* Surowy klucz (`key`) jest w tej odpowiedzi jeden jedyny raz i nie da sie go
* pozniej odczytac: w bazie jest tylko skrot. Panel ma go od razu pokazac
* administratorowi do skopiowania i przekazania odbiorcy.
*/
public record ApiKeyCreated(ApiKeyView apiKey, String key) {
}
@@ -0,0 +1,202 @@
package com.softspm.webinar.apikey;
import com.softspm.webinar.domain.ApiKey;
import com.softspm.webinar.repository.ApiKeyRepository;
import com.softspm.webinar.web.NotFoundException;
import com.softspm.webinar.web.ValidationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
/**
* Zarzadzanie kluczami API i ich weryfikacja.
*
* GENEROWANIE
* ---------------------------------------------------------------------------
* Klucz to 32 bajty (256 bitow) z SecureRandom, zakodowane base64url bez
* dopelnienia - 43 znaki bezpieczne w naglowku HTTP i w adresie. 256 bitow
* losowych czyni zgadywanie i atak slownikowy bezcelowymi.
*
* PRZECHOWYWANIE
* ---------------------------------------------------------------------------
* W bazie jest WYLACZNIE SHA-256 klucza. Surowy klucz zwracamy raz, przy
* utworzeniu, i nigdzie go nie zapisujemy: wyciek bazy nie wydaje dzialajacych
* kluczy. Skrot jest bez soli, bo przy 256 bitach losowych sol nic nie wnosi,
* a deterministyczny skrot pozwala odnalezc klucz jednym zapytaniem.
*/
@Service
public class ApiKeyService {
private static final Logger log = LoggerFactory.getLogger(ApiKeyService.class);
private static final SecureRandom RANDOM = new SecureRandom();
/** Dlugosc klucza w bajtach przed zakodowaniem. */
private static final int KEY_BYTES = 32;
/** Ile pierwszych znakow klucza zapisujemy jawnie, do rozpoznania w panelu. */
private static final int PREFIX_LENGTH = 8;
private final ApiKeyRepository repository;
public ApiKeyService(ApiKeyRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public List<ApiKeyView> list() {
return repository.findAllByOrderByCreatedAtDesc().stream()
.map(ApiKeyView::of)
.toList();
}
/**
* Tworzy klucz i zwraca go RAZ w postaci surowej.
*
* Surowy klucz jest w odpowiedzi tylko teraz: kolejne odczyty listy pokaza
* juz wylacznie prefiks. Administrator ma go od razu przekazac odbiorcy.
*/
@Transactional
public ApiKeyCreated create(String rawName) {
String name = rawName == null ? "" : rawName.trim();
if (name.isEmpty()) {
throw new ValidationException("Nazwa klucza jest wymagana");
}
if (name.length() > 120) {
throw new ValidationException("Nazwa klucza jest zbyt długa");
}
String secret = generateKey();
String hash = sha256Hex(secret);
// kolizja skrotu przy 256 bitach losowych jest praktycznie niemozliwa,
// ale unikalny indeks i tak jej pilnuje; sprawdzenie tutaj daje czytelny
// komunikat zamiast bledu integralnosci
if (repository.existsByKeyHash(hash)) {
throw new ValidationException("Wystąpił konflikt przy generowaniu klucza, spróbuj ponownie");
}
ApiKey entity = new ApiKey();
entity.setName(name);
entity.setKeyHash(hash);
entity.setKeyPrefix(secret.substring(0, PREFIX_LENGTH));
entity.setActive(true);
ApiKey saved = repository.save(entity);
log.info("Utworzono klucz API '{}' (prefiks {})", saved.getName(), saved.getKeyPrefix());
// surowy klucz wychodzi wylacznie tutaj i nie jest nigdzie zapisywany
return new ApiKeyCreated(ApiKeyView.of(saved), secret);
}
/**
* Generuje NOWY klucz dla istniejacego odbiorcy (rotacja).
*
* Nazwa i tozsamosc wpisu zostaja, ale poprzedni klucz przestaje dzialac
* natychmiast: podmieniamy skrot i prefiks, a `last_used_at` zerujemy, bo
* dotyczylo poprzedniego klucza. Surowy nowy klucz zwracamy raz, jak przy
* tworzeniu. Sluzy do odzyskania dostepu po zgubieniu klucza, ktorego z
* bazy odczytac sie nie da (jest tylko skrot).
*/
@Transactional
public ApiKeyCreated regenerate(Long id) {
ApiKey key = require(id);
String secret = generateKey();
String hash = sha256Hex(secret);
if (repository.existsByKeyHash(hash)) {
throw new ValidationException("Wystąpił konflikt przy generowaniu klucza, spróbuj ponownie");
}
key.setKeyHash(hash);
key.setKeyPrefix(secret.substring(0, PREFIX_LENGTH));
// poprzedni klucz przestaje dzialac; ostatnie uzycie dotyczylo jego
key.setLastUsedAt(null);
key.setActive(true);
key.setUpdatedAt(OffsetDateTime.now());
log.info("Wygenerowano ponownie klucz API '{}' (nowy prefiks {})",
key.getName(), key.getKeyPrefix());
return new ApiKeyCreated(ApiKeyView.of(key), secret);
}
/** Wlacza albo wylacza klucz. Wylaczony przestaje dzialac natychmiast. */
@Transactional
public ApiKeyView setActive(Long id, boolean active) {
ApiKey key = require(id);
key.setActive(active);
key.setUpdatedAt(OffsetDateTime.now());
log.info("Klucz API '{}' (prefiks {}) {}",
key.getName(), key.getKeyPrefix(), active ? "wlaczony" : "wylaczony");
return ApiKeyView.of(key);
}
/** Kasuje klucz na trwale. */
@Transactional
public void delete(Long id) {
ApiKey key = require(id);
repository.delete(key);
log.info("Skasowano klucz API '{}' (prefiks {})", key.getName(), key.getKeyPrefix());
}
/**
* Weryfikacja klucza z naglowka.
*
* Zwraca prawde dla klucza, ktory istnieje i jest aktywny. Przy trafieniu
* odnotowuje `last_used_at`, zeby w panelu bylo widac, czy klucz jeszcze
* zyje. Skrot liczymy zawsze, takze dla pustego wejscia, i porownanie idzie
* w bazie po indeksie, wiec sciezka nie ujawnia, czy sam skrot pasowal.
*/
@Transactional
public boolean verify(String rawKey) {
if (rawKey == null || rawKey.isBlank()) {
return false;
}
String hash = sha256Hex(rawKey.trim());
Optional<ApiKey> found = repository.findByKeyHashAndActiveTrue(hash);
if (found.isEmpty()) {
return false;
}
ApiKey key = found.get();
key.setLastUsedAt(OffsetDateTime.now());
return true;
}
private ApiKey require(Long id) {
return repository.findById(id)
.orElseThrow(() -> new NotFoundException("Nie znaleziono klucza API"));
}
/** 32 bajty z SecureRandom zakodowane base64url bez dopelnienia. */
private String generateKey() {
byte[] bytes = new byte[KEY_BYTES];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
/** SHA-256 wejscia jako 64 znaki hex. */
static String sha256Hex(String input) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(digest.length * 2);
for (byte b : digest) {
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
hex.append(Character.forDigit(b & 0xF, 16));
}
return hex.toString();
} catch (NoSuchAlgorithmException e) {
// SHA-256 jest w kazdej maszynie wirtualnej Javy: ten wyjatek nie wystapi
throw new IllegalStateException("Brak algorytmu SHA-256", e);
}
}
}
@@ -0,0 +1,31 @@
package com.softspm.webinar.apikey;
import com.softspm.webinar.domain.ApiKey;
import java.time.OffsetDateTime;
/**
* Widok klucza API dla panelu.
*
* NIGDY nie zawiera samego klucza ani jego skrotu: klucz istnieje w postaci
* surowej wylacznie w chwili utworzenia (zwraca go `ApiKeyCreated`), a skrotu
* nie wolno wystawiac. Panel rozpoznaje klucz po `prefix`.
*/
public record ApiKeyView(
Long id,
String name,
String prefix,
boolean active,
OffsetDateTime lastUsedAt,
OffsetDateTime createdAt) {
public static ApiKeyView of(ApiKey entity) {
return new ApiKeyView(
entity.getId(),
entity.getName(),
entity.getKeyPrefix(),
entity.isActive(),
entity.getLastUsedAt(),
entity.getCreatedAt());
}
}
@@ -0,0 +1,68 @@
package com.softspm.webinar.apikey;
import com.softspm.webinar.web.UnauthorizedException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Publiczne API tylko do odczytu, chronione kluczem API.
*
* DOSTEP
* ---------------------------------------------------------------------------
* Sciezka jest pod /public/**, wiec Spring Security ja przepuszcza (permitAll).
* Uwierzytelnienie robi TEN kontroler, sprawdzajac naglowek X-API-Key przez
* ApiKeyService.verify. Ten sam wzorzec co webhooki dostawcy wideo: otwarte
* w Security, a sekret sprawdzany w kontrolerze. Brak naglowka, pusty, bledny
* albo wylaczony klucz daje jednakowo 401, zeby odpowiedz nie ujawniala,
* ktory z warunkow zawiodl.
*
* WERSJA W SCIEZCE
* ---------------------------------------------------------------------------
* `/v1/` w adresie pozwala kiedys wystawic `/v2/` bez psucia integracji juz
* dzialajacych u odbiorcow.
*/
@RestController
@RequestMapping("/public/v1")
@Tag(name = "Publiczne API (klucz)", description = "Aktywne webinary i kursy, dostep przez naglowek X-API-Key")
public class PublicApiController {
/** Naglowek z kluczem. */
public static final String API_KEY_HEADER = "X-API-Key";
private final ApiKeyService apiKeys;
private final PublicApiService data;
public PublicApiController(ApiKeyService apiKeys, PublicApiService data) {
this.apiKeys = apiKeys;
this.data = data;
}
@GetMapping("/webinars")
@Operation(summary = "Aktywne webinary (opublikowane i niezakonczone). Wymaga X-API-Key.")
public List<PublicApiDtos.WebinarItem> webinars(
@RequestHeader(value = API_KEY_HEADER, required = false) String apiKey) {
authorize(apiKey);
return data.activeWebinars();
}
@GetMapping("/courses")
@Operation(summary = "Aktywne kursy (opublikowane). Wymaga X-API-Key.")
public List<PublicApiDtos.CourseItem> courses(
@RequestHeader(value = API_KEY_HEADER, required = false) String apiKey) {
authorize(apiKey);
return data.activeCourses();
}
/** Odrzuca zadanie bez waznego klucza. Jeden komunikat dla wszystkich przyczyn. */
private void authorize(String apiKey) {
if (!apiKeys.verify(apiKey)) {
throw new UnauthorizedException("Nieprawidłowy lub brakujący klucz API.");
}
}
}
@@ -0,0 +1,51 @@
package com.softspm.webinar.apikey;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* Widoki publicznego API z kluczem.
*
* ZWRACAMY WYLACZNIE DANE JAWNE PLUS STATYSTYKI.
* ---------------------------------------------------------------------------
* To sa te same dane, ktore i tak widzi gosc na stronie, powiekszone o liczby
* zapisanych i kursantow. NIGDY nie wolno dopisac tu danych uczestnikow,
* adresu `joinUrl` (link do sali), tresci platnych lekcji ani niczego, co nie
* jest publiczne. Kazde pole jest wypisane recznie, tak jak w PublicConfig:
* przez to nowe pole encji nie wycieka samym faktem dodania go do modelu.
*/
public final class PublicApiDtos {
private PublicApiDtos() {
}
/** Aktywny webinar. */
public record WebinarItem(
String slug,
String title,
String description,
OffsetDateTime startsAt,
OffsetDateTime endsAt,
String timezone,
boolean registrationOpen,
Integer capacity,
long registeredCount,
String registrationUrl) {
}
/** Aktywny (opublikowany) kurs. */
public record CourseItem(
String slug,
String title,
String subtitle,
String description,
String category,
String level,
Integer durationMinutes,
boolean free,
BigDecimal priceGross,
String currency,
long enrolledCount,
String courseUrl) {
}
}
@@ -0,0 +1,148 @@
package com.softspm.webinar.apikey;
import com.softspm.webinar.domain.Course;
import com.softspm.webinar.domain.EnrollmentStatus;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.repository.CourseRepository;
import com.softspm.webinar.repository.EnrollmentRepository;
import com.softspm.webinar.repository.RegistrationRepository;
import com.softspm.webinar.repository.WebinarRepository;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Sklada dane publicznego API: aktywne webinary i aktywne kursy, wraz ze
* statystykami.
*
* Wszystko dzieje sie w jednej transakcji, bo widoki sa skladane z encji
* pobieranych leniwie (granica transakcji: encje nie opuszczaja serwisu).
* Liczby zapisanych i kursantow ida ZAPYTANIAMI ZBIORCZYMI, nie po jednym na
* pozycje, tym samym wzorcem co listy w panelu.
*/
@Service
public class PublicApiService {
private final WebinarRepository webinars;
private final CourseRepository courses;
private final RegistrationRepository registrations;
private final EnrollmentRepository enrollments;
private final SettingsService settings;
public PublicApiService(WebinarRepository webinars,
CourseRepository courses,
RegistrationRepository registrations,
EnrollmentRepository enrollments,
SettingsService settings) {
this.webinars = webinars;
this.courses = courses;
this.registrations = registrations;
this.enrollments = enrollments;
this.settings = settings;
}
@Transactional(readOnly = true)
public List<PublicApiDtos.WebinarItem> activeWebinars() {
List<Webinar> active = webinars.findActive(OffsetDateTime.now());
Map<Long, Long> counts = registeredCounts(active);
String base = publicBaseUrl();
return active.stream()
.map(w -> new PublicApiDtos.WebinarItem(
w.getSlug(),
w.getTitle(),
w.getDescription(),
w.getStartsAt(),
w.getEndsAt(),
w.getTimezone(),
w.isRegistrationOpen(),
w.getCapacity(),
counts.getOrDefault(w.getId(), 0L),
base == null ? null : base + "/w/" + w.getSlug()))
.toList();
}
@Transactional(readOnly = true)
public List<PublicApiDtos.CourseItem> activeCourses() {
List<Course> published = courses.findPublished();
Map<Long, Long> counts = enrolledCounts(published);
String base = publicBaseUrl();
return published.stream()
.map(c -> new PublicApiDtos.CourseItem(
c.getSlug(),
c.getTitle(),
c.getSubtitle(),
c.getDescription(),
c.getCategory(),
c.getLevel() == null ? null : c.getLevel().name(),
c.getDurationMinutes(),
c.isFree(),
c.getPriceGross(),
c.getCurrency(),
counts.getOrDefault(c.getId(), 0L),
base == null ? null : base + "/kurs/" + c.getSlug()))
.toList();
}
/** Liczba zajetych miejsc per webinar, jednym zapytaniem zbiorczym. */
private Map<Long, Long> registeredCounts(List<Webinar> list) {
if (list.isEmpty()) {
return Map.of();
}
List<Long> ids = list.stream().map(Webinar::getId).toList();
Map<Long, Long> result = new HashMap<>();
for (Object[] row : registrations.countActiveByWebinarIds(ids)) {
result.put((Long) row[0], (Long) row[1]);
}
return result;
}
/**
* Liczba kursantow per kurs: ACTIVE i COMPLETED razem.
*
* Oba stany oznaczaja realnego kursanta (dostep do materialow zostaje po
* ukonczeniu). CANCELLED i EXPIRED sie nie licza. `countByCourseIds`
* przyjmuje jeden status, wiec sumujemy dwa przebiegi.
*/
private Map<Long, Long> enrolledCounts(List<Course> list) {
if (list.isEmpty()) {
return Map.of();
}
List<Long> ids = list.stream().map(Course::getId).toList();
Map<Long, Long> result = new HashMap<>();
addCounts(result, enrollments.countByCourseIds(ids, EnrollmentStatus.ACTIVE));
addCounts(result, enrollments.countByCourseIds(ids, EnrollmentStatus.COMPLETED));
return result;
}
private void addCounts(Map<Long, Long> target, List<Object[]> rows) {
for (Object[] row : rows) {
Long id = (Long) row[0];
Long count = (Long) row[1];
target.merge(id, count, Long::sum);
}
}
/**
* Publiczny adres instancji do budowania linkow, albo null.
*
* Zwracamy null zamiast rzucac wyjatek: brak adresu nie moze wywrocic
* calego API, a pozycja bez linku nadal niesie sensowne dane. Tak samo
* jak SummaryService pomija link do nagrania, gdy adresu nie ma.
*/
private String publicBaseUrl() {
String url = settings.get(SettingKey.APP_PUBLIC_URL);
if (url == null || url.isBlank()) {
return null;
}
String trimmed = url.trim();
return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
}
}
@@ -159,6 +159,13 @@ public class SecurityConfig {
auth.requestMatchers("/admin/banners/**").hasRole("ADMIN");
auth.requestMatchers("/admin/settings/**").hasRole("ADMIN");
auth.requestMatchers("/admin/users/**").hasRole("ADMIN");
/*
* Klucze API otwieraja dostep do calego publicznego API,
* wiec zarzadza nimi wylacznie ADMIN, jak ustawieniami
* i kontami. Sam publiczny endpoint /public/v1/** jest w
* permitAll powyzej i sprawdza klucz w kontrolerze.
*/
auth.requestMatchers("/admin/api-keys/**").hasRole("ADMIN");
// pozostale operacje administracyjne takze dla moderatora
auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR");
@@ -0,0 +1,69 @@
package com.softspm.webinar.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.OffsetDateTime;
/**
* Klucz API do publicznego API tylko do odczytu.
*
* W bazie jest WYLACZNIE skrot SHA-256 klucza (`keyHash`), nigdy sam klucz.
* Surowy klucz istnieje tylko w chwili utworzenia i jest pokazany raz.
* `keyPrefix` to kilka pierwszych znakow klucza, trzymane jawnie wylacznie po
* to, zeby rozpoznac klucz na liscie w panelu.
*/
@Entity
@Table(name = "api_keys")
@Getter
@Setter
@NoArgsConstructor
public class ApiKey {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** Nazwa odbiorcy, komu klucz wydano. */
@Column(name = "name", nullable = false, length = 120)
private String name;
/** SHA-256 klucza jako 64 znaki hex. */
@Column(name = "key_hash", nullable = false, length = 64)
private String keyHash;
/** Kilka pierwszych znakow klucza, jawnie, tylko do rozpoznania w panelu. */
@Column(name = "key_prefix", nullable = false, length = 16)
private String keyPrefix;
@Column(name = "active", nullable = false)
private boolean active = true;
@Column(name = "last_used_at")
private OffsetDateTime lastUsedAt;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (this.createdAt == null) {
this.createdAt = now;
}
if (this.updatedAt == null) {
this.updatedAt = now;
}
}
}
@@ -0,0 +1,25 @@
package com.softspm.webinar.repository;
import com.softspm.webinar.domain.ApiKey;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
/**
* Klucz po skrocie, tylko aktywny.
*
* Weryfikacja liczy SHA-256 klucza z naglowka i szuka po nim: skrot jest
* deterministyczny (klucz ma 256 bitow losowych, wiec sol jest zbedna),
* dzieki czemu wyszukanie to jedno zapytanie po indeksie UNIQUE. Wylaczony
* klucz nie jest znajdowany, wiec dezaktywacja odbiera dostep natychmiast.
*/
Optional<ApiKey> findByKeyHashAndActiveTrue(String keyHash);
boolean existsByKeyHash(String keyHash);
/** Lista do panelu, najnowsze na gorze. */
List<ApiKey> findAllByOrderByCreatedAtDesc();
}
@@ -95,6 +95,23 @@ public interface WebinarRepository extends JpaRepository<Webinar, Long> {
""")
List<Webinar> findUpcoming(@Param("now") OffsetDateTime now, Pageable pageable);
/**
* Webinary aktywne dla publicznego API: opublikowane i jeszcze nie
* zakonczone.
*
* Podzial idzie po `endsAt`, nie po statusie FINISHED, dokladnie jak w
* panelu (nic w aplikacji nie ustawia FINISHED). W przeciwienstwie do
* `findUpcoming` warunkiem jest `endsAt > now`, nie `startsAt > now`, wiec
* trwajacy webinar takze jest aktywny.
*/
@Query("""
select w from Webinar w
where w.status = com.softspm.webinar.domain.WebinarStatus.PUBLISHED
and w.endsAt > :now
order by w.startsAt asc
""")
List<Webinar> findActive(@Param("now") OffsetDateTime now);
/**
* Lista webinarow z wyszukiwaniem po tytule i filtrami.
*
@@ -43,6 +43,16 @@ public class ApiExceptionHandler {
return ResponseEntity.badRequest().body(ApiError.of(e.getMessage()));
}
/**
* Brak waznego klucza API w publicznym API. Zawsze 401 z tym samym
* komunikatem, niezaleznie od tego, czy naglowka nie bylo, czy klucz byl
* bledny albo wylaczony.
*/
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<ApiError> handleUnauthorized(UnauthorizedException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiError.of(e.getMessage()));
}
/** Bledy adnotacji walidacyjnych na polach DTO. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleFieldErrors(MethodArgumentNotValidException e) {
@@ -0,0 +1,15 @@
package com.softspm.webinar.web;
/**
* Zadanie bez waznego uwierzytelnienia, mapowane na 401.
*
* Uzywane przez publiczne API z kluczem: brak naglowka, pusty albo bledny
* klucz oraz klucz wylaczony daja ten sam wynik, zeby odpowiedz nie ujawniala,
* ktory z tych warunkow zawiodl.
*/
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String message) {
super(message);
}
}
@@ -0,0 +1,45 @@
-- Klucze API do publicznego, chronionego API tylko do odczytu.
--
-- DO CZEGO SLUZA
-- ---------------------------------------------------------------------------
-- Zewnetrzny odbiorca (inna strona klienta, integracja, agregator) pobiera
-- liste aktywnych webinarow i aktywnych kursow przez /api/public/v1/**,
-- podajac klucz w naglowku X-API-Key. Mozna wydac wiele kluczy dla roznych
-- odbiorcow, wlaczac je i wylaczac oraz kasowac niezaleznie.
--
-- W BAZIE JEST SKROT, NIE KLUCZ
-- ---------------------------------------------------------------------------
-- Trzymamy WYLACZNIE SHA-256 klucza (key_hash), nigdy samego klucza. Surowy
-- klucz pokazujemy jednorazowo w chwili utworzenia i nie da sie go odtworzyc:
-- wyciek bazy nie wydaje wtedy dzialajacych kluczy, dokladnie jak przy hasle.
-- Skrot jest deterministyczny (bez soli), bo klucz ma 256 bitow losowych z
-- SecureRandom - slownik teczowy nie ma czego zbudowac, a deterministyczny
-- skrot pozwala wyszukac klucz jednym zapytaniem przy weryfikacji.
--
-- key_prefix to kilka pierwszych znakow surowego klucza, przechowywane jawnie
-- WYLACZNIE do rozpoznania klucza w panelu ("ktory to z listy"). Sam prefiks
-- nie wystarcza do uwierzytelnienia.
--
-- CYKL ZYCIA
-- ---------------------------------------------------------------------------
-- active - wylaczenie odbiera dostep odwracalnie, bez kasowania sladu.
-- last_used_at - kiedy klucz byl ostatnio uzyty (podglad, czy jeszcze zyje).
-- Kasowanie usuwa wiersz na trwale: to odbiorca, ktorego juz nie obslugujemy.
CREATE TABLE api_keys (
id bigserial PRIMARY KEY,
-- nazwa odbiorcy, zeby wiedziec, komu klucz wydano
name varchar(120) NOT NULL,
-- SHA-256 klucza jako 64 znaki hex, unikalny: jeden klucz = jeden wiersz
key_hash varchar(64) NOT NULL,
-- kilka pierwszych znakow klucza, jawnie, tylko do rozpoznania w panelu
key_prefix varchar(16) NOT NULL,
active boolean NOT NULL DEFAULT true,
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_api_keys_key_hash UNIQUE (key_hash)
);
-- weryfikacja szuka po skrocie: indeks jest juz zapewniony przez UNIQUE wyzej,
-- wiec osobnego nie dodajemy