diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4fbe98..9e08bcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,11 @@ on: jobs: builds: runs-on: ubuntu-latest + env: + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + SECRET_KEY: ${{ secrets.SECRET_KEY }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} steps: - name: Checkout @@ -21,6 +26,14 @@ jobs: java-version: 24 cache: 'maven' + - name: Check Maven version + run: mvn --version + + - name: Check Java version + run: java -version + + + - name: Compile with Maven run: mvn -B --no-transfer-progress compile --file pom.xml diff --git a/.gitignore b/.gitignore index 526255e..221c5f3 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,4 @@ build/ ### VS Code ### .vscode/ -.env \ No newline at end of file +.env diff --git a/pom.xml b/pom.xml index 7db5c69..1399a68 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,23 @@ org.flywaydb flyway-database-postgresql + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.security + spring-security-test + test + + + org.thymeleaf.extras + thymeleaf-extras-springsecurity6 + + + org.springframework.boot + spring-boot-starter-oauth2-client + org.springframework.boot spring-boot-devtools diff --git a/src/main/java/org/storkforge/barkr/ai/controller/MistralAiController.java b/src/main/java/org/storkforge/barkr/ai/controller/MistralAiController.java index 983e7d3..38015a7 100644 --- a/src/main/java/org/storkforge/barkr/ai/controller/MistralAiController.java +++ b/src/main/java/org/storkforge/barkr/ai/controller/MistralAiController.java @@ -5,6 +5,7 @@ import org.springframework.ai.mistralai.MistralAiChatModel; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -24,6 +25,7 @@ public MistralAiController(MistralAiChatModel chatModel) { this.chatModel = chatModel; } + @PreAuthorize("hasRole('PREMIUM')") @GetMapping("/ai/generate") public ResponseEntity> generate() { try { @@ -35,4 +37,4 @@ public ResponseEntity> generate() { return ResponseEntity.internalServerError().body(Map.of(genKey, "Sorry, I couldn't generate a response. Please try again later.")); } } -} \ No newline at end of file +} diff --git a/src/main/java/org/storkforge/barkr/api/controller/ApiKeyController.java b/src/main/java/org/storkforge/barkr/api/controller/ApiKeyController.java new file mode 100644 index 0000000..c54544e --- /dev/null +++ b/src/main/java/org/storkforge/barkr/api/controller/ApiKeyController.java @@ -0,0 +1,114 @@ +package org.storkforge.barkr.api.controller; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import org.storkforge.barkr.domain.IssuedApiKeyService; +import org.storkforge.barkr.dto.apiKeyDto.GenerateApiKeyRequest; +import org.storkforge.barkr.dto.apiKeyDto.ResponseApiKeyOnce; +import org.storkforge.barkr.dto.apiKeyDto.UpdateApiKey; +import org.storkforge.barkr.mapper.ApiKeyMapper; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + + +@Controller +@RequestMapping("/apikeys") +public class ApiKeyController { + private final IssuedApiKeyService issuedApiKeyService; + + public ApiKeyController(IssuedApiKeyService issuedApiKeyService) { + this.issuedApiKeyService = issuedApiKeyService; + } + + @GetMapping("/apikeyform") + @PreAuthorize("hasRole('USER')") + public String showApiKeyForm(Model model) { + String now = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")); + model.addAttribute("now", now); + return "apikeys"; + } + + + @GetMapping("/result") + @PreAuthorize("hasRole('USER')") + public String showGeneratedApiKey(@ModelAttribute("response") ResponseApiKeyOnce response, Model model) { + if (response == null || response.value() == null) { + return "redirect:/apikeys/apikeyform"; + } + + model.addAttribute("response", response); + return "apikeys/result"; + } + + + @PostMapping("/generate") + @PreAuthorize("hasRole('USER')") + public String generateApiKey( + @RequestParam String apiKeyName, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME + ) LocalDateTime expiresAt, + RedirectAttributes redirectAttributes + ) throws NoSuchAlgorithmException, InvalidKeyException { + GenerateApiKeyRequest inputRequest = new GenerateApiKeyRequest(apiKeyName, expiresAt); + GenerateApiKeyRequest request = ApiKeyMapper.normalizeExpiresAt(inputRequest); + + String apiKey; + String hashedApiKey; + + do{ + apiKey = issuedApiKeyService.generateRawApiKey(); + hashedApiKey = issuedApiKeyService.hashedApiKey(apiKey); + } while (issuedApiKeyService.apiKeyExists(hashedApiKey)); + issuedApiKeyService.apiKeyGenerate(request, hashedApiKey); + + ResponseApiKeyOnce response = new ResponseApiKeyOnce( + "API-KEY", apiKey, "Please store these credentials safely"); + + redirectAttributes.addFlashAttribute("response", response); + + return "redirect:/apikeys/result"; + + + } + + @GetMapping("/mykeys") + public String myKeys(Model model , @AuthenticationPrincipal OidcUser user) { + var currentUser = issuedApiKeyService.findByGoogleOidc2Id(user.getName()); + var keys = issuedApiKeyService.allApiKeys(user.getName()); + model.addAttribute("account", currentUser.get().getAccount()); + model.addAttribute("keys", keys.apiKeys()); + return "apikeys/mykeys"; + + } + + @PostMapping("/mykeys/revoke") + public String revokeKey(@RequestParam String referenceId, @AuthenticationPrincipal OidcUser user) { + if (issuedApiKeyService.isValidUuid(UUID.fromString(referenceId), user.getName())) { + var update = new UpdateApiKey(UUID.fromString(referenceId),null, true, null); + issuedApiKeyService.updateApiKey(update); + } + return "redirect:/apikeys/mykeys"; + } + + @PostMapping("mykeys/nameupdate") + public String nameUpdate(@RequestParam String apiKeyName, @RequestParam String referenceId, @AuthenticationPrincipal OidcUser user) { + if(issuedApiKeyService.isValidUuid(UUID.fromString(referenceId), user.getName())) { + var update = new UpdateApiKey(UUID.fromString(referenceId), apiKeyName, false, null); + issuedApiKeyService.updateApiKey(update); + } + return "redirect:/apikeys/mykeys"; + } + +} diff --git a/src/main/java/org/storkforge/barkr/config/Config.java b/src/main/java/org/storkforge/barkr/config/Config.java index 11caad0..d3e83d1 100644 --- a/src/main/java/org/storkforge/barkr/config/Config.java +++ b/src/main/java/org/storkforge/barkr/config/Config.java @@ -3,6 +3,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cache.annotation.EnableCaching; +import org.storkforge.barkr.domain.entity.GoogleAccountApiKeyLink; import org.storkforge.barkr.domain.entity.Post; import org.springframework.boot.CommandLineRunner; import org.storkforge.barkr.domain.entity.Account; @@ -10,10 +11,13 @@ import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.client.WebClient; +import org.storkforge.barkr.domain.roles.BarkrRole; import org.storkforge.barkr.infrastructure.persistence.PostRepository; import org.storkforge.barkr.infrastructure.persistence.AccountRepository; +import java.util.HashSet; import java.util.List; +import java.util.Set; @Configuration @Profile("!test") @@ -31,17 +35,36 @@ CommandLineRunner accountInit(AccountRepository accountRepository, PostRepositor Account accountOne = new Account(); accountOne.setUsername("Bella Pawkins"); accountOne.setBreed("Golden Retriever"); + accountOne.setGoogleOidc2Id("1"); accountOne.setImage(null); + accountOne.setRoles(new HashSet<>(Set.of(BarkrRole.USER))); Account accountTwo = new Account(); accountTwo.setUsername("Charlie Barkson"); accountTwo.setBreed("Siberian Husky"); + accountTwo.setGoogleOidc2Id("2"); accountTwo.setImage(null); + accountTwo.setRoles(new HashSet<>(Set.of(BarkrRole.USER))); Account accountThree = new Account(); accountThree.setUsername("Max Woofington"); accountThree.setBreed("German Shepherd"); + accountThree.setGoogleOidc2Id("3"); accountThree.setImage(null); + accountThree.setRoles(new HashSet<>(Set.of(BarkrRole.USER))); + + GoogleAccountApiKeyLink link1 = new GoogleAccountApiKeyLink(); + link1.setAccount(accountOne); + accountOne.setGoogleAccountApiKeyLink(link1); + + GoogleAccountApiKeyLink link2 = new GoogleAccountApiKeyLink(); + link2.setAccount(accountTwo); + accountTwo.setGoogleAccountApiKeyLink(link2); + + GoogleAccountApiKeyLink link3 = new GoogleAccountApiKeyLink(); + link3.setAccount(accountThree); + accountThree.setGoogleAccountApiKeyLink(link3); + accountRepository.saveAll(List.of(accountOne, accountTwo, accountThree)); diff --git a/src/main/java/org/storkforge/barkr/config/SecurityConfig.java b/src/main/java/org/storkforge/barkr/config/SecurityConfig.java new file mode 100644 index 0000000..a396f20 --- /dev/null +++ b/src/main/java/org/storkforge/barkr/config/SecurityConfig.java @@ -0,0 +1,94 @@ +package org.storkforge.barkr.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.storkforge.barkr.domain.IssuedApiKeyService; +import org.storkforge.barkr.domain.roles.BarkrRole; +import org.storkforge.barkr.filters.ApiKeyAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + @Order(3) + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{ + http + .oauth2Login(Customizer.withDefaults()) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/","/login", "/error", "/css/**", "/js/**", "/images/**").permitAll() + .requestMatchers("/post/load").permitAll() + .requestMatchers( "/account/{id}/image").permitAll() + .requestMatchers("/ai/generate").hasRole(BarkrRole.PREMIUM.name()) + .requestMatchers("/post/add").authenticated() + .requestMatchers("/account/{id}/upload").authenticated() + .requestMatchers("/{username}").authenticated() + .requestMatchers("/apikeys/generate").authenticated() + .requestMatchers("/apikeys/apikeyform").authenticated() + .requestMatchers("/apikeys/result").authenticated() + .requestMatchers("/apikeys/mykeys").authenticated() + .requestMatchers("/apikeys/mykeys/revoke").authenticated() + .requestMatchers("/apikeys/mykeys/nameupdate").authenticated() + .requestMatchers("/unlock-easter-egg").authenticated() + + .anyRequest().denyAll() + + ) + .logout(logout -> logout + .logoutUrl("/logout") + .invalidateHttpSession(true) + .clearAuthentication(true) + .deleteCookies("JSESSIONID") + .logoutSuccessUrl("/barkr/logout") + .permitAll() + + ).csrf(csrf -> csrf + .ignoringRequestMatchers("/logout", "/ai/generate") + ); + + return http.build(); + } + + + @Bean + @Order(2) + public SecurityFilterChain restAPIAndGraphQLFilterChain( + HttpSecurity http, + IssuedApiKeyService issuedApiKeyService) throws Exception { + http.securityMatcher("/api/**", "/graphql") + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .csrf(AbstractHttpConfigurer::disable) + .addFilterAfter(new ApiKeyAuthenticationFilter(issuedApiKeyService), LogoutFilter.class) + .authorizeHttpRequests( + authorize -> authorize + .requestMatchers("/api/accounts").authenticated() + .requestMatchers("/api/posts").authenticated() + .requestMatchers("/api/accounts/{id}").authenticated() + .requestMatchers("/api/posts/{id}").authenticated() + .requestMatchers("/api/keys").authenticated() + .requestMatchers(HttpMethod.POST, "/graphql").authenticated() + .anyRequest().denyAll() + + ); + return http.build(); + + } + + + + + + + +} diff --git a/src/main/java/org/storkforge/barkr/domain/AccountService.java b/src/main/java/org/storkforge/barkr/domain/AccountService.java index 51f2e66..c0d1c29 100644 --- a/src/main/java/org/storkforge/barkr/domain/AccountService.java +++ b/src/main/java/org/storkforge/barkr/domain/AccountService.java @@ -30,6 +30,10 @@ public AccountService(AccountRepository accountRepository) { this.accountRepository = accountRepository; } + public Optional findByGoogleOidc2Id(String googleOidc2Id) { + return accountRepository.findByGoogleOidc2Id(googleOidc2Id); + } + @Cacheable("allAccounts") public Page findAll(Pageable pageable) { log.info("Finding all accounts"); @@ -85,4 +89,10 @@ public void updateImage(Long id, byte[] image) { accountRepository.save(account); } + + + + + + } diff --git a/src/main/java/org/storkforge/barkr/domain/CustomOidc2UserService.java b/src/main/java/org/storkforge/barkr/domain/CustomOidc2UserService.java new file mode 100644 index 0000000..25bafce --- /dev/null +++ b/src/main/java/org/storkforge/barkr/domain/CustomOidc2UserService.java @@ -0,0 +1,76 @@ +package org.storkforge.barkr.domain; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.storkforge.barkr.domain.entity.Account; +import org.storkforge.barkr.domain.entity.GoogleAccountApiKeyLink; +import org.storkforge.barkr.domain.roles.BarkrRole; +import org.storkforge.barkr.infrastructure.persistence.AccountRepository; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Transactional +public class CustomOidc2UserService extends OidcUserService { + private final Logger log = LoggerFactory.getLogger(CustomOidc2UserService.class); + + private final AccountRepository accountRepository; + + public CustomOidc2UserService(AccountRepository accountRepository) { + this.accountRepository = accountRepository; + } + + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + OidcUser user = super.loadUser(userRequest); + String username = user.getAttributes().get("name").toString(); + String oidcId = user.getName(); + Account account; + + var accountFound = accountRepository.findByGoogleOidc2Id(user.getName()); + + if (accountFound.isPresent()) { + account = accountFound.get(); + + } else { + + account = new Account(); + GoogleAccountApiKeyLink link = new GoogleAccountApiKeyLink(); + account.setUsername(username); + account.setBreed("Snoopy"); + account.setGoogleOidc2Id(oidcId); + account.setImage(null); + account.setRoles(new HashSet<>(Set.of(BarkrRole.USER))); + link.setAccount(account); + log.info("New record added to database"); + account.setGoogleAccountApiKeyLink(link); + try { + + accountRepository.save(account); + } catch (Exception e) { + throw new OAuth2AuthenticationException("Failed to save account " + account); + } + } + + Set mappedAuthorities = account.getRoles().stream() + .map(role -> new SimpleGrantedAuthority(role.getAuthorityValue())) + .collect(Collectors.toSet()); + + return new DefaultOidcUser(mappedAuthorities, user.getIdToken(), user.getUserInfo()); + + } + + +} diff --git a/src/main/java/org/storkforge/barkr/domain/IssuedApiKeyService.java b/src/main/java/org/storkforge/barkr/domain/IssuedApiKeyService.java new file mode 100644 index 0000000..fc129de --- /dev/null +++ b/src/main/java/org/storkforge/barkr/domain/IssuedApiKeyService.java @@ -0,0 +1,173 @@ +package org.storkforge.barkr.domain; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.storkforge.barkr.domain.entity.GoogleAccountApiKeyLink; +import org.storkforge.barkr.domain.entity.IssuedApiKey; +import org.storkforge.barkr.dto.apiKeyDto.CreateApiKey; +import org.storkforge.barkr.dto.apiKeyDto.GenerateApiKeyRequest; +import org.storkforge.barkr.dto.apiKeyDto.ResponseApiKeyList; +import org.storkforge.barkr.dto.apiKeyDto.UpdateApiKey; +import org.storkforge.barkr.exceptions.AccountNotFound; +import org.storkforge.barkr.exceptions.IssuedApiKeyNotFound; +import org.storkforge.barkr.infrastructure.persistence.GoogleAccountApiKeyLinkRepository; +import org.storkforge.barkr.infrastructure.persistence.IssuedApiKeyRepository; +import org.storkforge.barkr.mapper.ApiKeyMapper; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +@Service +@Transactional +public class IssuedApiKeyService { + + private final Logger log = LoggerFactory.getLogger(IssuedApiKeyService.class); + + private final IssuedApiKeyRepository issuedApiKeyRepository; + private final GoogleAccountApiKeyLinkRepository googleAccountApiKeyLinkRepository; + @Value("${barkr.api.secret}") + private String secretKey; + + + @Autowired + public IssuedApiKeyService( + IssuedApiKeyRepository issuedApiKeyRepository, + GoogleAccountApiKeyLinkRepository googleAccountApiKeyLinkRepository) { + this.issuedApiKeyRepository = issuedApiKeyRepository; + this.googleAccountApiKeyLinkRepository = googleAccountApiKeyLinkRepository; + + } + + public Optional findByGoogleOidc2Id(String name) { + return Optional.ofNullable(googleAccountApiKeyLinkRepository.findByAccount_GoogleOidc2Id(name).orElseThrow(() -> new AccountNotFound("Account was not found"))); + + } + + + public Optional issuedApiKeyExists(String hashedApiKey) { + log.info("Checking if issued api key exists"); + return issuedApiKeyRepository.findByHashedApiKey(hashedApiKey); + } + + public void apiKeyGenerate(GenerateApiKeyRequest request, String hashedApiKey) { + SecurityContext securityContext = SecurityContextHolder.getContext(); + Authentication authentication = securityContext.getAuthentication(); + var googleOidc2id = authentication.getName(); + var link = googleAccountApiKeyLinkRepository.findByAccount_GoogleOidc2Id(googleOidc2id).orElseThrow(() -> new AccountNotFound("Account was not found! ")); + LocalDateTime issueDate = LocalDateTime.now(); + + CreateApiKey createApiKey = new CreateApiKey( + hashedApiKey, + issueDate, + request.expiresAt(), + false, + issueDate, + request.apiKeyName(), + link, + generateUuid() + ); + + log.info("Adding new api key to account"); + + IssuedApiKey key = ApiKeyMapper.mapToEntity(createApiKey); + + if(key != null) + issuedApiKeyRepository.save(key); + + } + + public String generateRawApiKey() { + byte[] lengthOfKey = new byte[32]; + SecureRandom random = new SecureRandom(); + random.nextBytes(lengthOfKey); + return Base64.getUrlEncoder().withoutPadding().encodeToString(lengthOfKey); + + + } + + public UUID generateUuid() { + Optional uuidFound; + UUID uuid; + do { + uuid = UUID.randomUUID(); + uuidFound = issuedApiKeyRepository.findByReferenceId(uuid); + }while(uuidFound.isPresent()); + + return uuid; + } + + + public String hashedApiKey(String rawApiKey) throws NoSuchAlgorithmException, InvalidKeyException { + + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(), "HmacSHA256"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(keySpec); + + byte[] hmac = mac.doFinal(rawApiKey.getBytes()); + return Base64.getEncoder().encodeToString(hmac); + + } + + public boolean apiKeyExists(String hashedApiKey) { + log.info("Checking if issued api key exists"); + return issuedApiKeyRepository.findByHashedApiKey(hashedApiKey).isPresent(); + } + + public boolean apiKeyValidation(String rawApiKey) throws NoSuchAlgorithmException, InvalidKeyException { + var hashedApikey = hashedApiKey(rawApiKey); + var keyFound = issuedApiKeyRepository.findByHashedApiKey(hashedApikey); + return keyFound.isPresent() && !keyFound.get().isRevoked(); + + } + + public ResponseApiKeyList allApiKeys(String googleOidc2Id) { + + var recordsRemoved = revokeExpiredApiKeys(); + log.info("Removed {} api keys", recordsRemoved); + + var link = googleAccountApiKeyLinkRepository.findByAccount_GoogleOidc2Id(googleOidc2Id).orElseThrow(() -> new AccountNotFound("Account was not found! ")); + var apiKeys = issuedApiKeyRepository.findByGoogleAccountApiKeyLinkOrderByIssuedAtDesc(link); + + var apiKeyResponse = apiKeys.stream().map(ApiKeyMapper::mapToResponse).toList(); + return new ResponseApiKeyList(apiKeyResponse); + + } + + + public void updateApiKey(UpdateApiKey updateApiKey) { + var apikey = issuedApiKeyRepository.findByReferenceId(updateApiKey.referenceId()).orElseThrow(() -> new IssuedApiKeyNotFound("No api key was found")); + var updateApikey = ApiKeyMapper.updateIssuedApiKey(apikey, updateApiKey); + issuedApiKeyRepository.save(updateApikey); + + } + + public int revokeExpiredApiKeys() { + return issuedApiKeyRepository.revokeExpiredKeys(LocalDateTime.now()); + } + + public boolean isValidUuid(UUID uuid, String googleOidc2Id) { + var allApiKeys = allApiKeys(googleOidc2Id); + return allApiKeys.apiKeys().stream() + .anyMatch(k -> k.referenceId().equals(uuid)); + + } + + + + + +} diff --git a/src/main/java/org/storkforge/barkr/domain/PostService.java b/src/main/java/org/storkforge/barkr/domain/PostService.java index 4a52503..6a49e74 100644 --- a/src/main/java/org/storkforge/barkr/domain/PostService.java +++ b/src/main/java/org/storkforge/barkr/domain/PostService.java @@ -18,7 +18,6 @@ import org.storkforge.barkr.infrastructure.persistence.PostRepository; import org.storkforge.barkr.mapper.PostMapper; -import java.util.List; @Service @Transactional diff --git a/src/main/java/org/storkforge/barkr/domain/entity/Account.java b/src/main/java/org/storkforge/barkr/domain/entity/Account.java index bf0f5f7..a3b0b45 100644 --- a/src/main/java/org/storkforge/barkr/domain/entity/Account.java +++ b/src/main/java/org/storkforge/barkr/domain/entity/Account.java @@ -3,12 +3,14 @@ import jakarta.persistence.*; import jakarta.validation.constraints.*; import org.hibernate.proxy.HibernateProxy; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.storkforge.barkr.domain.roles.BarkrRole; import java.io.Serializable; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; +import java.util.*; +import java.util.stream.Collectors; @Entity public class Account implements Serializable { @@ -34,9 +36,54 @@ public class Account implements Serializable { @Size(min = 2, max = 100, message = "Breed name must be between 2 and 100 characters") private String breed; + @NotBlank + @Column(name = "google_oidc2id", unique = true, nullable = false, updatable = false) + private String googleOidc2Id; + + @OneToMany(mappedBy = "account", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List posts = new ArrayList<>(); + @OneToOne(fetch = FetchType.LAZY, mappedBy = "account", cascade = CascadeType.ALL) + private GoogleAccountApiKeyLink googleAccountApiKeyLink; + + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable(name = "account_roles", joinColumns = @JoinColumn(name = "account_id")) + @Enumerated(EnumType.STRING) + @Column(name = "role") + private Set roles = new HashSet<>(); + + public Set getAuthorities() { + return roles.stream() + .map(role -> new SimpleGrantedAuthority(role.getAuthorityValue())) + .collect(Collectors.toSet()); + } + + public void setRoles(Set roles) { + this.roles = roles; + } + + public Set getRoles() { + return roles; + } + + + public GoogleAccountApiKeyLink getGoogleAccountApiKeyLink() { + return googleAccountApiKeyLink; + } + + public void setGoogleAccountApiKeyLink(GoogleAccountApiKeyLink googleAccountApiKeyLink) { + this.googleAccountApiKeyLink = googleAccountApiKeyLink; + } + + public String getGoogleOidc2Id() { + return googleOidc2Id; + } + + public void setGoogleOidc2Id(String googleOidc2Id) { + this.googleOidc2Id = googleOidc2Id; + } + @PrePersist protected void onCreate() { if (createdAt == null) createdAt = LocalDateTime.now(); @@ -107,12 +154,17 @@ public final int hashCode() { return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); } + + + @Override public String toString() { - return getClass().getSimpleName() + "(" + - "id = " + id + ", " + - "username = " + username + ", " + - "createdAt = " + createdAt + ", " + - "breed = " + breed + ")"; + return getClass().getSimpleName() + "(" + + "id = " + id + ", " + + "username = " + username + ", " + + "createdAt = " + createdAt + ", " + + "image = " + image + ", " + + "breed = " + breed + ", " + + "googleOidc2Id = " + googleOidc2Id + ")"; } } diff --git a/src/main/java/org/storkforge/barkr/domain/entity/GoogleAccountApiKeyLink.java b/src/main/java/org/storkforge/barkr/domain/entity/GoogleAccountApiKeyLink.java new file mode 100644 index 0000000..ea3e30d --- /dev/null +++ b/src/main/java/org/storkforge/barkr/domain/entity/GoogleAccountApiKeyLink.java @@ -0,0 +1,77 @@ +package org.storkforge.barkr.domain.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import org.hibernate.proxy.HibernateProxy; + +import java.io.Serializable; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +@Entity +@Table(name = "google_account_api_key_link") +public class GoogleAccountApiKeyLink implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "google_oidc2id", referencedColumnName = "google_oidc2id", nullable = false, unique = true, updatable = false) + private Account account; + + @OneToMany(mappedBy = "googleAccountApiKeyLink", cascade = CascadeType.ALL, orphanRemoval = true) + private Set issuedApiKeys = new LinkedHashSet<>(); + + public Set getIssuedApiKeys() { + return issuedApiKeys; + } + + public void setIssuedApiKeys(Set issuedApiKeys) { + this.issuedApiKeys = issuedApiKeys; + } + + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + + public Account getAccount() { + return account; + } + + public void setAccount(Account account) { + this.account = account; + } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); + Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + GoogleAccountApiKeyLink that = (GoogleAccountApiKeyLink) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "(" + + "id = " + id + ")"; + } + + +} diff --git a/src/main/java/org/storkforge/barkr/domain/entity/IssuedApiKey.java b/src/main/java/org/storkforge/barkr/domain/entity/IssuedApiKey.java new file mode 100644 index 0000000..7f1586e --- /dev/null +++ b/src/main/java/org/storkforge/barkr/domain/entity/IssuedApiKey.java @@ -0,0 +1,171 @@ +package org.storkforge.barkr.domain.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import org.hibernate.proxy.HibernateProxy; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "issued_api_key") +public class IssuedApiKey implements Serializable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false, updatable = false, unique = true) + private Long id; + + @NotBlank + @Column(nullable = false, updatable = false, unique = true) + private String hashedApiKey; + + @Column(nullable = false, updatable = false) + @NotNull + @PastOrPresent + private LocalDateTime issuedAt; + + @Column(nullable = false, updatable = false) + @NotNull + @FutureOrPresent + private LocalDateTime expiresAt; + + @Column(nullable = false) + private boolean revoked = false; + + @Column(nullable = false) + @NotNull + @PastOrPresent + private LocalDateTime lastUsedAt; + + @Column(nullable = false) + @NotBlank + private String apiKeyName; + + @Column(nullable = false, updatable = false, unique = true) + @NotNull + private UUID referenceId; + + @NotNull + @ManyToOne + @JoinColumn(name = "google_account_api_key_link_id") + private GoogleAccountApiKeyLink googleAccountApiKeyLink; + + + public GoogleAccountApiKeyLink getGoogleAccountApiKeyLink() { + return googleAccountApiKeyLink; + } + + public void setGoogleAccountApiKeyLink(GoogleAccountApiKeyLink googleAccountApiKeyLink) { + this.googleAccountApiKeyLink = googleAccountApiKeyLink; + } + + @PrePersist + protected void onCreate() { + if(issuedAt == null) issuedAt = LocalDateTime.now(); + if(expiresAt == null) expiresAt = issuedAt.plusMinutes(5); + if(lastUsedAt == null) lastUsedAt = issuedAt; + } + + + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getHashedApiKey() { + return hashedApiKey; + } + + public void setHashedApiKey(String hashedApiKey) { + this.hashedApiKey = hashedApiKey; + } + + public LocalDateTime getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(LocalDateTime issuedAt) { + this.issuedAt = issuedAt; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public boolean isRevoked() { + return revoked; + } + + public void setRevoked(boolean revoked) { + this.revoked = revoked; + } + + public LocalDateTime getLastUsedAt() { + return lastUsedAt; + } + + public void setLastUsedAt(LocalDateTime lastUsedAt) { + this.lastUsedAt = lastUsedAt; + } + + public String getApiKeyName() { + return apiKeyName; + } + + public void setApiKeyName(String apiKeyName) { + this.apiKeyName = apiKeyName; + } + + + public UUID getReferenceId() { + return referenceId; + } + + public void setReferenceId(UUID referenceId) { + this.referenceId = referenceId; + } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); + Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + IssuedApiKey that = (IssuedApiKey) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); + } + + + @Override + public String toString() { + return getClass().getSimpleName() + "(" + + "id = " + id + ", " + + "hashedApiKey = [REDACTED], " + + "issuedAt = " + issuedAt + ", " + + "expiresAt = " + expiresAt + ", " + + "revoked = " + revoked + ", " + + "lastUsedAt = " + lastUsedAt + ", " + + "apiKeyName = " + apiKeyName + ", " + + "referenceId = " + referenceId + ", " + + "googleAccountApiKeyLink = " + googleAccountApiKeyLink + ")"; + } +} diff --git a/src/main/java/org/storkforge/barkr/domain/roles/BarkrRole.java b/src/main/java/org/storkforge/barkr/domain/roles/BarkrRole.java new file mode 100644 index 0000000..66dbd5a --- /dev/null +++ b/src/main/java/org/storkforge/barkr/domain/roles/BarkrRole.java @@ -0,0 +1,22 @@ +package org.storkforge.barkr.domain.roles; + +public enum BarkrRole { + + USER("ROLE_USER"), + PREMIUM("ROLE_PREMIUM"); + + + + private final String authorityValue; + + BarkrRole(String roleValue) { + this.authorityValue = roleValue; + } + + public String getAuthorityValue() { + return authorityValue; + } + + + +} diff --git a/src/main/java/org/storkforge/barkr/dto/apiKeyDto/CreateApiKey.java b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/CreateApiKey.java new file mode 100644 index 0000000..97be937 --- /dev/null +++ b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/CreateApiKey.java @@ -0,0 +1,22 @@ +package org.storkforge.barkr.dto.apiKeyDto; + +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PastOrPresent; +import org.storkforge.barkr.domain.entity.GoogleAccountApiKeyLink; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; + +public record CreateApiKey( + @NotBlank String hashedApiKey, + @PastOrPresent LocalDateTime issuedAt, + @FutureOrPresent LocalDateTime expiresAt, + @NotNull Boolean revoked, + @PastOrPresent LocalDateTime lastUsedAt, + @NotBlank String apiKeyName, + @NotNull GoogleAccountApiKeyLink googleAccountApiKeyLink, + @NotNull UUID referenceId) implements Serializable { +} diff --git a/src/main/java/org/storkforge/barkr/dto/apiKeyDto/GenerateApiKeyRequest.java b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/GenerateApiKeyRequest.java new file mode 100644 index 0000000..964d4db --- /dev/null +++ b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/GenerateApiKeyRequest.java @@ -0,0 +1,11 @@ +package org.storkforge.barkr.dto.apiKeyDto; + +import jakarta.validation.constraints.NotBlank; + +import java.io.Serializable; +import java.time.LocalDateTime; + +public record GenerateApiKeyRequest( + @NotBlank String apiKeyName , + LocalDateTime expiresAt) implements Serializable { +} diff --git a/src/main/java/org/storkforge/barkr/dto/apiKeyDto/ResponseApiKey.java b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/ResponseApiKey.java new file mode 100644 index 0000000..9804288 --- /dev/null +++ b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/ResponseApiKey.java @@ -0,0 +1,18 @@ +package org.storkforge.barkr.dto.apiKeyDto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.io.Serializable; +import java.util.UUID; + +public record ResponseApiKey( + @NotBlank String issuedAt, + @NotBlank String expiresAt, + @NotBlank String lastUsedAt, + @NotBlank String apiKeyName, + @NotNull Boolean revoked, + @NotNull UUID referenceId + + ) implements Serializable { +} diff --git a/src/main/java/org/storkforge/barkr/dto/apiKeyDto/ResponseApiKeyList.java b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/ResponseApiKeyList.java new file mode 100644 index 0000000..347d74b --- /dev/null +++ b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/ResponseApiKeyList.java @@ -0,0 +1,6 @@ +package org.storkforge.barkr.dto.apiKeyDto; + +import java.util.List; + +public record ResponseApiKeyList(List apiKeys) { +} diff --git a/src/main/java/org/storkforge/barkr/dto/apiKeyDto/ResponseApiKeyOnce.java b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/ResponseApiKeyOnce.java new file mode 100644 index 0000000..832a7b9 --- /dev/null +++ b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/ResponseApiKeyOnce.java @@ -0,0 +1,11 @@ +package org.storkforge.barkr.dto.apiKeyDto; + +import jakarta.validation.constraints.NotBlank; + +import java.io.Serializable; + +public record ResponseApiKeyOnce( + @NotBlank String key, + @NotBlank String value, + @NotBlank String message) implements Serializable { +} diff --git a/src/main/java/org/storkforge/barkr/dto/apiKeyDto/UpdateApiKey.java b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/UpdateApiKey.java new file mode 100644 index 0000000..d6fcbc2 --- /dev/null +++ b/src/main/java/org/storkforge/barkr/dto/apiKeyDto/UpdateApiKey.java @@ -0,0 +1,14 @@ +package org.storkforge.barkr.dto.apiKeyDto; + +import jakarta.validation.constraints.NotNull; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; + +public record UpdateApiKey( + @NotNull UUID referenceId, + String apiKeyName, + Boolean revoke, + LocalDateTime lastUsedAt) implements Serializable { +} diff --git a/src/main/java/org/storkforge/barkr/exceptions/IssuedApiKeyNotFound.java b/src/main/java/org/storkforge/barkr/exceptions/IssuedApiKeyNotFound.java new file mode 100644 index 0000000..f1af204 --- /dev/null +++ b/src/main/java/org/storkforge/barkr/exceptions/IssuedApiKeyNotFound.java @@ -0,0 +1,14 @@ +package org.storkforge.barkr.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class IssuedApiKeyNotFound extends RuntimeException { + public IssuedApiKeyNotFound() { + super(); + } + public IssuedApiKeyNotFound(String message) { + super(message); + } +} diff --git a/src/main/java/org/storkforge/barkr/filters/ApiKeyAuthenticationFilter.java b/src/main/java/org/storkforge/barkr/filters/ApiKeyAuthenticationFilter.java new file mode 100644 index 0000000..8c66b14 --- /dev/null +++ b/src/main/java/org/storkforge/barkr/filters/ApiKeyAuthenticationFilter.java @@ -0,0 +1,76 @@ +package org.storkforge.barkr.filters; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; +import org.storkforge.barkr.domain.IssuedApiKeyService; +import org.storkforge.barkr.dto.apiKeyDto.UpdateApiKey; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; + +public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { + + IssuedApiKeyService issuedApiKeyService; + + public ApiKeyAuthenticationFilter(IssuedApiKeyService issuedApiKeyService) { + this.issuedApiKeyService = issuedApiKeyService; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String apiKey = request.getHeader("API-KEY"); + + try { + if (isValidApiKey(apiKey)) { + Authentication auth = new ApiKeyAuthenticationToken(apiKey); + SecurityContextHolder.getContext().setAuthentication(auth); + + var hashedApiKey = issuedApiKeyService.hashedApiKey(apiKey); + var foundApikey = issuedApiKeyService.issuedApiKeyExists(hashedApiKey); + + if (foundApikey.isPresent()) { + var updateApikey = new UpdateApiKey( + foundApikey.get().getReferenceId(), + null, + null, + LocalDateTime.now()); + issuedApiKeyService.updateApiKey(updateApikey); + } + + + } + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + + } catch (InvalidKeyException e) { + throw new RuntimeException(e); + } + + + filterChain.doFilter(request, response); + + } + + private boolean isValidApiKey(String apiKey) throws NoSuchAlgorithmException, InvalidKeyException { + issuedApiKeyService.revokeExpiredApiKeys(); + if (apiKey == null) { + return false; + } + return issuedApiKeyService.apiKeyValidation(apiKey); + + } + + + +} diff --git a/src/main/java/org/storkforge/barkr/filters/ApiKeyAuthenticationToken.java b/src/main/java/org/storkforge/barkr/filters/ApiKeyAuthenticationToken.java new file mode 100644 index 0000000..9ffa7a1 --- /dev/null +++ b/src/main/java/org/storkforge/barkr/filters/ApiKeyAuthenticationToken.java @@ -0,0 +1,44 @@ +package org.storkforge.barkr.filters; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.Collections; +import java.util.Objects; + +public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { + private final String apiKey; + + public ApiKeyAuthenticationToken(String apiKey) { + super(Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))); + this.apiKey = apiKey; + super.setAuthenticated(true); + } + + public String getApiKey() { + return apiKey; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return apiKey; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + ApiKeyAuthenticationToken that = (ApiKeyAuthenticationToken) o; + return Objects.equals(apiKey, that.apiKey); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), apiKey); + } +} diff --git a/src/main/java/org/storkforge/barkr/infrastructure/persistence/AccountRepository.java b/src/main/java/org/storkforge/barkr/infrastructure/persistence/AccountRepository.java index 65878fe..a6abcfa 100644 --- a/src/main/java/org/storkforge/barkr/infrastructure/persistence/AccountRepository.java +++ b/src/main/java/org/storkforge/barkr/infrastructure/persistence/AccountRepository.java @@ -1,5 +1,6 @@ package org.storkforge.barkr.infrastructure.persistence; +import jakarta.validation.constraints.NotBlank; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.storkforge.barkr.domain.entity.Account; @@ -11,4 +12,9 @@ public interface AccountRepository extends JpaRepository { @Query("SELECT a.image FROM Account a WHERE a.id = :id") Optional getAccountImage(Long id); + + + Optional findByGoogleOidc2Id(@NotBlank String googleOidc2Id); + + } diff --git a/src/main/java/org/storkforge/barkr/infrastructure/persistence/GoogleAccountApiKeyLinkRepository.java b/src/main/java/org/storkforge/barkr/infrastructure/persistence/GoogleAccountApiKeyLinkRepository.java new file mode 100644 index 0000000..96257dd --- /dev/null +++ b/src/main/java/org/storkforge/barkr/infrastructure/persistence/GoogleAccountApiKeyLinkRepository.java @@ -0,0 +1,13 @@ +package org.storkforge.barkr.infrastructure.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.storkforge.barkr.domain.entity.GoogleAccountApiKeyLink; + +import java.util.Optional; + +public interface GoogleAccountApiKeyLinkRepository extends JpaRepository { + @Override + Optional findById(Long id); + Optional findByAccount_GoogleOidc2Id(String googleOidc2id); + +} diff --git a/src/main/java/org/storkforge/barkr/infrastructure/persistence/IssuedApiKeyRepository.java b/src/main/java/org/storkforge/barkr/infrastructure/persistence/IssuedApiKeyRepository.java new file mode 100644 index 0000000..19e3d16 --- /dev/null +++ b/src/main/java/org/storkforge/barkr/infrastructure/persistence/IssuedApiKeyRepository.java @@ -0,0 +1,27 @@ +package org.storkforge.barkr.infrastructure.persistence; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.storkforge.barkr.domain.entity.GoogleAccountApiKeyLink; +import org.storkforge.barkr.domain.entity.IssuedApiKey; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface IssuedApiKeyRepository extends JpaRepository { + + Optional findByHashedApiKey(String hashedApiKey); + + List findByGoogleAccountApiKeyLinkOrderByIssuedAtDesc(@NotNull GoogleAccountApiKeyLink googleAccountApiKeyLink); + + Optional findByReferenceId(@NotNull UUID referenceId); + + @Modifying + @Query("UPDATE IssuedApiKey k SET k.revoked = true WHERE k.expiresAt <= :now AND k.revoked = false") + int revokeExpiredKeys(@Param("now") LocalDateTime now); + +} diff --git a/src/main/java/org/storkforge/barkr/mapper/ApiKeyMapper.java b/src/main/java/org/storkforge/barkr/mapper/ApiKeyMapper.java new file mode 100644 index 0000000..e980c5b --- /dev/null +++ b/src/main/java/org/storkforge/barkr/mapper/ApiKeyMapper.java @@ -0,0 +1,104 @@ +package org.storkforge.barkr.mapper; + +import org.storkforge.barkr.domain.entity.IssuedApiKey; +import org.storkforge.barkr.dto.apiKeyDto.CreateApiKey; +import org.storkforge.barkr.dto.apiKeyDto.GenerateApiKeyRequest; +import org.storkforge.barkr.dto.apiKeyDto.ResponseApiKey; +import org.storkforge.barkr.dto.apiKeyDto.UpdateApiKey; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class ApiKeyMapper { + + private ApiKeyMapper() { + } + + public static IssuedApiKey mapToEntity(CreateApiKey createApiKey) { + IssuedApiKey issuedApiKey = new IssuedApiKey(); + + if (createApiKey == null) { + return null; + } + + issuedApiKey.setApiKeyName(createApiKey.apiKeyName()); + issuedApiKey.setHashedApiKey(createApiKey.hashedApiKey()); + issuedApiKey.setGoogleAccountApiKeyLink(createApiKey.googleAccountApiKeyLink()); + issuedApiKey.setIssuedAt(createApiKey.issuedAt()); + issuedApiKey.setRevoked(createApiKey.revoked()); + issuedApiKey.setExpiresAt(createApiKey.expiresAt()); + issuedApiKey.setLastUsedAt(createApiKey.lastUsedAt()); + issuedApiKey.setReferenceId(createApiKey.referenceId()); + + return issuedApiKey; + } + + + public static ResponseApiKey mapToResponse(IssuedApiKey issuedApiKey) { + if (issuedApiKey == null) { + return null; + } + + return new ResponseApiKey( + formatDateAndTime(issuedApiKey.getIssuedAt()), + formatDateAndTime(issuedApiKey.getExpiresAt()), + formatDateAndTime(issuedApiKey.getLastUsedAt()), + issuedApiKey.getApiKeyName(), + issuedApiKey.isRevoked(), + issuedApiKey.getReferenceId() + + ); + + } + + public static GenerateApiKeyRequest normalizeExpiresAt(GenerateApiKeyRequest createApiKey) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime inputDate = createApiKey.expiresAt(); + + if (inputDate == null) { + inputDate = LocalDateTime.now().plusMinutes(5); + } + + if (inputDate != null && inputDate.isBefore(now)) { + inputDate = LocalDateTime.now().plusMinutes(5); + } + + if (inputDate != null && inputDate.isAfter(now.plusDays(15))) { + inputDate = LocalDateTime.now().plusDays(15); + } + + + + return new GenerateApiKeyRequest(createApiKey.apiKeyName(), inputDate); + + } + + public static String formatDateAndTime(LocalDateTime dateTime) { + if (dateTime != null) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + return dateTime.format(formatter); + } + return ""; + } + + public static IssuedApiKey updateIssuedApiKey(IssuedApiKey issuedApiKey, UpdateApiKey updateApiKey) { + if (issuedApiKey == null || updateApiKey == null || issuedApiKey.getApiKeyName() == null) { + return null; + } + + if (updateApiKey.revoke() != null) { + issuedApiKey.setRevoked(updateApiKey.revoke()); + } + + if (updateApiKey.apiKeyName() != null) { + issuedApiKey.setApiKeyName(updateApiKey.apiKeyName()); + } + + if(updateApiKey.lastUsedAt() != null) { + issuedApiKey.setLastUsedAt(updateApiKey.lastUsedAt()); + } + return issuedApiKey; + + } + +} diff --git a/src/main/java/org/storkforge/barkr/web/controller/WebController.java b/src/main/java/org/storkforge/barkr/web/controller/WebController.java index 2e71e50..95cb7bd 100644 --- a/src/main/java/org/storkforge/barkr/web/controller/WebController.java +++ b/src/main/java/org/storkforge/barkr/web/controller/WebController.java @@ -9,7 +9,16 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.ui.Model; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; @@ -18,12 +27,17 @@ import org.storkforge.barkr.domain.AccountService; import org.storkforge.barkr.domain.DogFactService; import org.storkforge.barkr.domain.PostService; +import org.storkforge.barkr.domain.roles.BarkrRole; import org.storkforge.barkr.dto.accountDto.ResponseAccount; import org.storkforge.barkr.dto.postDto.CreatePost; import org.storkforge.barkr.dto.postDto.ResponsePost; +import org.storkforge.barkr.exceptions.AccountNotFound; import java.io.IOException; import java.io.InputStream; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; @Controller @RequestMapping("/") @@ -37,21 +51,35 @@ public WebController(PostService postService, AccountService accountService, Dog this.postService = postService; this.accountService = accountService; this.dogFactService = dogFactService; + } @GetMapping("/") - public String index(Model model, @PageableDefault Pageable pageable) { + public String index(Model model, @PageableDefault Pageable pageable, @AuthenticationPrincipal OidcUser user) { + long id = 1L; + + if(user != null) { + var currentUser = accountService.findByGoogleOidc2Id(user.getName()); + id = currentUser.isEmpty() ? 1L : currentUser.get().getId(); + } + + model.addAttribute("posts", postService.findAll(pageable)); - model.addAttribute("createPostDto", new CreatePost("", 1L)); + model.addAttribute("createPostDto", new CreatePost("", id)); model.addAttribute("fact", dogFactService.getDogFact()); - // TODO: Change this to the actual account once security is in place - model.addAttribute("account", accountService.findById(1L)); + model.addAttribute("account", accountService.findById(id)); return "index"; } @GetMapping("/{username}") - public String user(@PathVariable("username") @NotBlank String username, Model model, RedirectAttributes redirectAttributes, @PageableDefault Pageable pageable) { + public String user(@PathVariable("username") @NotBlank String username, Model model, RedirectAttributes redirectAttributes, @PageableDefault Pageable pageable, @AuthenticationPrincipal OidcUser user) { + Long id = 1L; + if(user != null) { + var currentUser = accountService.findByGoogleOidc2Id(user.getName()); + id = currentUser.isEmpty() ? 1L : currentUser.get().getId(); + username = currentUser.get().getUsername().isEmpty() ? username : currentUser.get().getUsername(); + } ResponseAccount queryAccount; try { queryAccount = accountService.findByUsername(username); @@ -61,15 +89,23 @@ public String user(@PathVariable("username") @NotBlank String username, Model mo return "redirect:/"; } + + + model.addAttribute("accountPosts", postService.findByUsername(username, pageable)); model.addAttribute("queryAccount", queryAccount); model.addAttribute("fact", dogFactService.getDogFact()); - // TODO: Change this to the actual account once security is in place - model.addAttribute("account", accountService.findById(1L)); + model.addAttribute("account", accountService.findById(id)); return "profile"; } + @GetMapping("/barkr/logout") + public String barkrLogout(){ + SecurityContextHolder.clearContext(); + return "fragments/barkr-logout"; + } + @GetMapping("/post/load") public String loadPosts(@RequestParam("page") int page, Model model, @PageableDefault Pageable pageable) { pageable = PageRequest.of(page, pageable.getPageSize()); @@ -136,5 +172,45 @@ public String uploadImage(@PathVariable @Positive @NotNull Long id, @RequestPara accountService.updateImage(id, file.getBytes()); return "redirect:/"; } -} + +@PostMapping("/unlock-easter-egg") +public ResponseEntity premiumUser(@RequestParam String code, @AuthenticationPrincipal OidcUser user){ + + if(user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Login Required \uD83D\uDE1C"); + } + + var sanitizedCode = code.toLowerCase().replaceAll("[^a-zA-Z0-9]", ""); + + if (!sanitizedCode.trim().equals("upupdowndownleftrightleftrightba")) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Better try next time: \uD83D\uDE1C"); + + } + + var account = accountService.findByGoogleOidc2Id(user.getName()).orElseThrow(() -> new AccountNotFound("No account found")); + account.setRoles(new HashSet<>(Set.of(BarkrRole.USER, BarkrRole.PREMIUM))); + + Set mappedAuthorities = account.getRoles().stream() + .map(role -> new SimpleGrantedAuthority(role.getAuthorityValue())) + .collect(Collectors.toSet()); + + OidcUser updateUser = new DefaultOidcUser(mappedAuthorities, user.getIdToken(), user.getUserInfo()); + String registrationId = ((OAuth2AuthenticationToken) SecurityContextHolder.getContext() + .getAuthentication()) + .getAuthorizedClientRegistrationId(); + + OAuth2AuthenticationToken auth = new OAuth2AuthenticationToken(updateUser, updateUser.getAuthorities(), registrationId); + + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(auth); + + + return ResponseEntity.status(HttpStatus.ACCEPTED).body("Congrats: \uD83D\uDC4D"); + + } + + + + +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 63eef62..703e238 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -2,7 +2,22 @@ spring.docker.compose.lifecycle-management=start_only spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.graphql.graphiql.enabled=true + +logging.level.org.springframework.security=DEBUG +logging.level.org.springframework.security.oauth2=DEBUG + spring.config.import=optional:file:.env[.properties] + spring.ai.mistralai.base-url=https://api.mistral.ai/v1 spring.ai.mistralai.api-key=${MISTRAL_API_KEY} -spring.cache.type=redis \ No newline at end of file +spring.cache.type=redis + + +spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} +spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} +spring.security.oauth2.client.registration.google.scope=openid, profile, email +spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/oauth2/code/google + +# Custom security key + +barkr.api.secret=${SECRET_KEY} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8b621a8..fa90998 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,4 +2,11 @@ spring.application.name=barkr spring.messages.fallback-to-system-locale=false server.error.include-stacktrace=never spring.servlet.multipart.max-file-size=5MB -ai.mistral.joke-prompt=tell me a dog joke like you are a dog and dont write anything else and dont explain the joke \ No newline at end of file +spring.config.import=optional:file:.env[.properties] +ai.mistral.joke-prompt=tell me a dog joke like you are a dog and dont write anything else and dont explain the joke +spring.ai.mistralai.api-key=${MISTRAL_API_KEY} +barkr.api.secret=${SECRET_KEY:default-secret-key} +spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} +spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} +spring.security.oauth2.client.registration.google.scope=openid, profile, email +spring.security.oauth2.client.registration.google.redirect-uri={baseUrl}/login/oauth2/code/google diff --git a/src/main/resources/db/migration/V3__Add_unique_constraints_on_username_and_google_oidc2id.sql b/src/main/resources/db/migration/V3__Add_unique_constraints_on_username_and_google_oidc2id.sql new file mode 100644 index 0000000..26cf4e1 --- /dev/null +++ b/src/main/resources/db/migration/V3__Add_unique_constraints_on_username_and_google_oidc2id.sql @@ -0,0 +1,15 @@ +ALTER TABLE account + ADD google_oidc2id VARCHAR(255); + +UPDATE account +SET google_oidc2id = account_id::TEXT +WHERE google_oidc2id IS NULL; + +ALTER TABLE account + ALTER COLUMN google_oidc2id SET NOT NULL; + +ALTER TABLE account + ADD CONSTRAINT uc_account_googleoidc2id UNIQUE (google_oidc2id); + + +CREATE UNIQUE INDEX idx_account_google_oidc2_id ON account (google_oidc2id); diff --git a/src/main/resources/db/migration/V4__Create_google_account_api_key_link_table.sql b/src/main/resources/db/migration/V4__Create_google_account_api_key_link_table.sql new file mode 100644 index 0000000..758f73e --- /dev/null +++ b/src/main/resources/db/migration/V4__Create_google_account_api_key_link_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE google_account_api_key_link +( + id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + google_oidc2id VARCHAR(255) NOT NULL, + CONSTRAINT pk_google_account_api_key_link PRIMARY KEY (id), + CONSTRAINT fk_gaakl_account_google_oidc2_id FOREIGN KEY (google_oidc2id) REFERENCES account (google_oidc2id) +); + +ALTER TABLE google_account_api_key_link + ADD CONSTRAINT uc_google_account_api_key_link_google_oidc2 UNIQUE (google_oidc2id); diff --git a/src/main/resources/db/migration/V5__Create_issued_api_key_table.sql .sql b/src/main/resources/db/migration/V5__Create_issued_api_key_table.sql .sql new file mode 100644 index 0000000..227d291 --- /dev/null +++ b/src/main/resources/db/migration/V5__Create_issued_api_key_table.sql .sql @@ -0,0 +1,18 @@ +CREATE TABLE issued_api_key +( + id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + hashed_api_key VARCHAR(255) NOT NULL, + issued_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + expires_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + revoked BOOLEAN NOT NULL, + last_used_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + api_key_name VARCHAR(255) NOT NULL, + google_account_api_key_link_id BIGINT NOT NULL, + CONSTRAINT pk_issued_api_key PRIMARY KEY (id) +); + +ALTER TABLE issued_api_key + ADD CONSTRAINT uc_issued_api_key_hashedapikey UNIQUE (hashed_api_key); + +ALTER TABLE issued_api_key + ADD CONSTRAINT FK_ISSUED_API_KEY_ON_GOOGLE_ACCOUNT_API_KEY_LINK FOREIGN KEY (google_account_api_key_link_id) REFERENCES google_account_api_key_link (id); diff --git a/src/main/resources/db/migration/V6__add_roles_column_to_account_table.sql b/src/main/resources/db/migration/V6__add_roles_column_to_account_table.sql new file mode 100644 index 0000000..bcffdd2 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_roles_column_to_account_table.sql @@ -0,0 +1,8 @@ + +CREATE TABLE IF NOT EXISTS account_roles ( + account_id BIGINT NOT NULL, + role VARCHAR(255) NOT NULL DEFAULT 'ROLE_USER', + FOREIGN KEY (account_id) REFERENCES account(account_id) ON DELETE CASCADE + ); + +CREATE INDEX IF NOT EXISTS idx_account_roles_account_id ON account_roles(account_id); diff --git a/src/main/resources/db/migration/V7__adds_referenceID_uuid_to_apikey_table.sql b/src/main/resources/db/migration/V7__adds_referenceID_uuid_to_apikey_table.sql new file mode 100644 index 0000000..bae6d07 --- /dev/null +++ b/src/main/resources/db/migration/V7__adds_referenceID_uuid_to_apikey_table.sql @@ -0,0 +1,15 @@ + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +ALTER TABLE issued_api_key + ADD COLUMN reference_id UUID; + +UPDATE issued_api_key +SET reference_id = gen_random_uuid() +WHERE reference_id IS NULL; + +ALTER TABLE issued_api_key + ALTER COLUMN reference_id SET NOT NULL; + +ALTER TABLE issued_api_key + ADD CONSTRAINT unique_reference_id UNIQUE (reference_id); diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 4041fe0..865d68c 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -7,6 +7,8 @@ create.post=Bark It! home=Home profile=Profile edit.profile=Edit Profile +apikey.gen=Generate API Key breed=Breed: footer=� 2025 Barkr - The Social Network for Dogs. All Rights Reserved. -success=Successfully barked! \ No newline at end of file +success=Successfully barked! +mykeys=My Keys diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties index 2f9ffa3..633efb1 100644 --- a/src/main/resources/messages_en.properties +++ b/src/main/resources/messages_en.properties @@ -6,7 +6,12 @@ create.form.placeholder=Share your pawsome thoughts... create.post=Bark It! home=Home profile=Profile +mykeys=My Keys edit.profile=Edit Profile breed=Breed: footer=© 2025 Barkr - The Social Network for Dogs. All Rights Reserved. -success=Successfully barked! \ No newline at end of file +success=Successfully barked! +apikey.revoke=Revoke +apikey.nameupdate=Edit Name +code.label=Enter Secret Code: +code.button=Unlock diff --git a/src/main/resources/messages_sv.properties b/src/main/resources/messages_sv.properties index 696fa42..2d6bdf0 100644 --- a/src/main/resources/messages_sv.properties +++ b/src/main/resources/messages_sv.properties @@ -6,7 +6,13 @@ create.form.placeholder=Dela med dig av dina tass-tastiska tankar... create.post=Voffa! home=Hem profile=Profil +mykeys=Mina Nycklar edit.profile=Redigera profil breed=Ras footer=© 2025 Barkr - Sociala nätverket för hundar. Alla rättigheter reserverade. -success=Lyckades voffa! \ No newline at end of file +success=Lyckades voffa! +apikey.revoke=Annulera +apikey.nameupdate=Byt Namn +apikey.gen= Skapa API Nyckel +code.label=Knappa in den hemliga koden +code.button=Lås Upp diff --git a/src/main/resources/messages_uwu.properties b/src/main/resources/messages_uwu.properties index 5e2cbcc..9649256 100644 --- a/src/main/resources/messages_uwu.properties +++ b/src/main/resources/messages_uwu.properties @@ -9,4 +9,10 @@ profile=Pwofiwe~ ✨ edit.profile=Edit youw pwetty pwofiwe~ 💅🐾 breed=Bwreed~: footer=© 2025 Bawkʳ - Da Soshuwu Netwowk fow Doggos~ All Wights Wesewved~ 💞 -success=Succcwessfuwwy bawked! Yip yip~! 🐕💕 \ No newline at end of file +success=Succcwessfuwwy bawked! Yip yip~! 🐕💕 +apikey.revoke=Wevoke +apikey.nameupdate=Edit Nyame +apikey.gen=Create Secrte Kez +mykeys=My Secwets +code.label=uwu secrte enter +code.button=unwock my pawew diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 6d20088..3f9401d 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -459,4 +459,67 @@ i { .profile-stats { justify-content: center; } -} \ No newline at end of file + + .form-card { + background-color: var(--card-bg); + padding: 20px; + border-radius: 10px; + box-shadow: 0 2px 10px var(--shadow); + max-width: 600px; + margin: 0 auto; + } + + .form-card h2 { + margin-bottom: 20px; + color: var(--heading); + } + + .form-group { + margin-bottom: 15px; + } + + .form-group label { + display: block; + margin-bottom: 6px; + font-weight: bold; + color: var(--text); + } + + .form-group input[type="text"], + .form-group input[type="datetime-local"], + .form-group textarea, + .form-group select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 5px; + background-color: var(--background); + color: var(--text); + font-size: 16px; + transition: border-color 0.3s, box-shadow 0.3s; + } + + .form-group input:focus, + .form-group textarea:focus, + .form-group select:focus { + outline: none; + border-color: var(--secondary); + box-shadow: 0 0 5px var(--secondary); + } + + .form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + } + + .form-actions .btn { + padding: 10px 20px; + } + + .table-responsive { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + +} diff --git a/src/main/resources/static/js/copy-to-clipboard.js b/src/main/resources/static/js/copy-to-clipboard.js new file mode 100644 index 0000000..163fefa --- /dev/null +++ b/src/main/resources/static/js/copy-to-clipboard.js @@ -0,0 +1,13 @@ + function copyKey() { + const keyInput = document.getElementById("rawKey"); + const valueInput = document.getElementById("apiKey"); + + const key = keyInput.value; + const value = valueInput.value; + + const credentials = `key: ${value}\nvalue: ${key}`; + + navigator.clipboard.writeText(credentials) + .then(() => alert("API key copied!")) + .catch(err => alert("Failed to copy.")); +} diff --git a/src/main/resources/static/js/generate-joke.js b/src/main/resources/static/js/generate-joke.js index fa1e4e1..3c91c13 100644 --- a/src/main/resources/static/js/generate-joke.js +++ b/src/main/resources/static/js/generate-joke.js @@ -1,8 +1,16 @@ -document.getElementById('generate-joke-btn').addEventListener('click', function() { - fetch('/ai/generate') - .then(response => response.json()) - .then(data => { - document.getElementById('joke-display').textContent = data.generation; - }) - .catch(error => console.error('Error fetching joke:', error)); + +document.addEventListener("DOMContentLoaded", function() { + const jokeBtn = document.getElementById('generate-joke-btn'); + if (jokeBtn) { + jokeBtn.addEventListener('click', function() { + fetch('/ai/generate') + .then(response => response.json()) + .then(data => { + document.getElementById('joke-display').textContent = data.generation; + }) + .catch(error => console.error('Error fetching joke:', error)); + }); + } else { + console.error("Button #generate-joke-btn not found! User might not have ROLE_PREMIUM."); + } }); diff --git a/src/main/resources/static/js/unlock-easter-egg.js b/src/main/resources/static/js/unlock-easter-egg.js new file mode 100644 index 0000000..7d6fae9 --- /dev/null +++ b/src/main/resources/static/js/unlock-easter-egg.js @@ -0,0 +1,32 @@ +document.addEventListener('DOMContentLoaded', function () { + var form = document.getElementById('unlock-premium-form'); + if (!form) return; + + form.addEventListener('submit', function(event) { + event.preventDefault(); + + var code = document.getElementById('code').value; + var csrfToken = document.getElementById('csrfToken').value; + + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/unlock-easter-egg', true); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken); + + var body = 'code=' + encodeURIComponent(code); // <-- manually build form data string + + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + var responseText = xhr.responseText; + if (xhr.status === 200 || xhr.status === 202) { + document.getElementById('premium-response').textContent = responseText; + } else { + console.error("XHR error:", responseText); + document.getElementById('premium-response').textContent = '❌ ' + responseText; + } + } + }; + + xhr.send(body); + }); +}); diff --git a/src/main/resources/static/js/update-apikey-name.js b/src/main/resources/static/js/update-apikey-name.js new file mode 100644 index 0000000..483fbb4 --- /dev/null +++ b/src/main/resources/static/js/update-apikey-name.js @@ -0,0 +1,10 @@ +function promptAndSetName(form) { + const input = form.querySelector('#nameUpdater'); + const currentValue = input.value; + + const newName = prompt("Enter a new name for the API key:", currentValue); + if (newName && newName.trim() !== "") { + input.value = newName.trim(); + form.submit(); + } +} diff --git a/src/main/resources/templates/apikeys.html b/src/main/resources/templates/apikeys.html new file mode 100644 index 0000000..5a70e80 --- /dev/null +++ b/src/main/resources/templates/apikeys.html @@ -0,0 +1,33 @@ + + + + + + + + Generate API Key + + + +
+

Generate API Key

+
+
+ + +
+ +
+ + +
+ + + +
+ +
+
+
+ + diff --git a/src/main/resources/templates/apikeys/mykeys.html b/src/main/resources/templates/apikeys/mykeys.html new file mode 100644 index 0000000..fc9b02d --- /dev/null +++ b/src/main/resources/templates/apikeys/mykeys.html @@ -0,0 +1,109 @@ + + + + API Keys + + + + + + + + +
+
+
+ +
+

API Keys

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
API Key NameIssued AtExpires AtLast Used AtReference IDRevokedActions
test2025-04-20T15:442025-04-20T15:492025-04-20T15:4400-000-000-0000false +
+ + +
+ +
+ + + +
+
+
+
+ +
+ + + + + diff --git a/src/main/resources/templates/apikeys/result.html b/src/main/resources/templates/apikeys/result.html new file mode 100644 index 0000000..eb46009 --- /dev/null +++ b/src/main/resources/templates/apikeys/result.html @@ -0,0 +1,33 @@ + + + + API Key Created + + + + + +
+

Your API Key

+ +
+ + +
+ +
+ + +
+ +

Please store these credentials safely.

+ +
+ + Done +
+
+ + + + diff --git a/src/main/resources/templates/fragments/barkr-logout.html b/src/main/resources/templates/fragments/barkr-logout.html new file mode 100644 index 0000000..b9947d1 --- /dev/null +++ b/src/main/resources/templates/fragments/barkr-logout.html @@ -0,0 +1,24 @@ + + + + + + Title Logging Out of Barkr + + +

Hope to see you!

+

You have been logged out of Barkr App via Google.

+ + + + + diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index 292fdc2..6646400 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -7,8 +7,11 @@

Barkr

- - + Sign in + +
+ +
+
@@ -30,8 +29,11 @@

Create a New Bark

-
- +
+
@@ -46,4 +48,4 @@

Create a New Bark

- \ No newline at end of file + diff --git a/src/main/resources/templates/profile.html b/src/main/resources/templates/profile.html index 3606ddb..42a88d1 100644 --- a/src/main/resources/templates/profile.html +++ b/src/main/resources/templates/profile.html @@ -1,5 +1,5 @@ - + @@ -18,9 +18,26 @@
Profile Picture -
+
+
+ +
+ +
+

🎮 Unlock Premium

+
+
+ + + +
+
+
+ +

Unknown account

@@ -73,6 +90,7 @@

Edit Profile

+ - \ No newline at end of file + diff --git a/src/test/java/org/storkforge/barkr/api/controller/ApiAccountControllerTest.java b/src/test/java/org/storkforge/barkr/api/controller/ApiAccountControllerTest.java index 8b3c6b6..48d37fa 100644 --- a/src/test/java/org/storkforge/barkr/api/controller/ApiAccountControllerTest.java +++ b/src/test/java/org/storkforge/barkr/api/controller/ApiAccountControllerTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -35,6 +36,7 @@ class ApiAccountControllerTest { @Test @DisplayName("Returns all accounts from service") + @WithMockUser void accountsReturnsAllAccounts() throws Exception { List mockAccounts = List.of( new ResponseAccount(1L, "testaccount", LocalDateTime.now(), "beagle", new byte[0]), @@ -56,6 +58,7 @@ void accountsReturnsAllAccounts() throws Exception { } @Test + @WithMockUser void findAccount() throws Exception { ResponseAccount mockAccount = new ResponseAccount(1L, "testAccount", LocalDateTime.now(), "beagle", new byte[0]); when(service.findById(1L)).thenReturn(mockAccount); @@ -69,6 +72,7 @@ void findAccount() throws Exception { } @Test + @WithMockUser @DisplayName("Handles error for nonexistent id") void handlesErrorForNonexistentId() throws Exception { when(service.findById(1L)).thenThrow(new AccountNotFound("Account with id: 1 was not found")); @@ -77,4 +81,4 @@ void handlesErrorForNonexistentId() throws Exception { .andDo(print()) .andExpect((status().isNotFound())); } -} \ No newline at end of file +} diff --git a/src/test/java/org/storkforge/barkr/api/controller/ApiKeyControllerTest.java b/src/test/java/org/storkforge/barkr/api/controller/ApiKeyControllerTest.java new file mode 100644 index 0000000..97e52f6 --- /dev/null +++ b/src/test/java/org/storkforge/barkr/api/controller/ApiKeyControllerTest.java @@ -0,0 +1,249 @@ +package org.storkforge.barkr.api.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.storkforge.barkr.domain.IssuedApiKeyService; +import org.storkforge.barkr.domain.entity.Account; +import org.storkforge.barkr.domain.entity.GoogleAccountApiKeyLink; +import org.storkforge.barkr.dto.apiKeyDto.GenerateApiKeyRequest; +import org.storkforge.barkr.dto.apiKeyDto.ResponseApiKeyList; +import org.storkforge.barkr.dto.apiKeyDto.ResponseApiKeyOnce; +import org.storkforge.barkr.dto.apiKeyDto.UpdateApiKey; +import org.storkforge.barkr.infrastructure.persistence.AccountRepository; +import org.storkforge.barkr.infrastructure.persistence.GoogleAccountApiKeyLinkRepository; +import org.storkforge.barkr.infrastructure.persistence.IssuedApiKeyRepository; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + + +@WebMvcTest(ApiKeyController.class) +@AutoConfigureMockMvc +@WithMockUser(roles = "USER") + +class ApiKeyControllerTest { + + @Autowired + private MockMvc mvc; + + @MockitoBean + private IssuedApiKeyService issuedApiKeyService; + + @MockitoBean + private IssuedApiKeyRepository issuedApiKeyRepository; + + @MockitoBean + private GoogleAccountApiKeyLinkRepository googleAccountApiKeyLinkRepository; + + @MockitoBean + private AccountRepository accountRepository; + + private OAuth2AuthenticationToken mockOAuth2Auth(String name) { + OidcUser mockUser = Mockito.mock(OidcUser.class); + when(mockUser.getName()).thenReturn(name); + + return new OAuth2AuthenticationToken( + mockUser, + List.of(new SimpleGrantedAuthority("ROLE_USER")), + "oidc" + ); + } + + private static org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder postWithCsrf(String url) { + return post(url).with(csrf()); + } + + + + @BeforeEach + void setupMocks() throws NoSuchAlgorithmException, InvalidKeyException { + // Define behavior for issuedApiKeyService mock + when(issuedApiKeyService.generateRawApiKey()).thenReturn("mockRawApiKey"); + when(issuedApiKeyService.hashedApiKey("mockRawApiKey")).thenReturn("mockHashedApiKey"); + when(issuedApiKeyService.apiKeyExists("mockHashedApiKey")).thenReturn(false); + } + + + @Test + @DisplayName("GET /apikeys/apikeyform returns form view") + void showApiKeyForm() throws Exception { + mvc.perform(get("/apikeys/apikeyform")) + .andExpect(status().isOk()) + .andExpect(view().name("apikeys")); + } + + + @Test + @WithMockUser(username = "testUser", roles = {"USER"}) + void testGenerateApiKey() throws Exception { + GenerateApiKeyRequest request = new GenerateApiKeyRequest("TestKey", LocalDateTime.now().plusDays(1)); + String rawApiKey = "mockRawApiKey"; + String hashedApiKey = "mockHashedApiKey"; + + when(issuedApiKeyService.generateRawApiKey()).thenReturn(rawApiKey); + when(issuedApiKeyService.hashedApiKey(rawApiKey)).thenReturn(hashedApiKey); + when(issuedApiKeyService.apiKeyExists(hashedApiKey)).thenReturn(false); + + mvc.perform(post("/apikeys/generate").with(csrf()) + .param("apiKeyName", request.apiKeyName()) + .param("expiresAt", request.expiresAt().toString())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/apikeys/result")); + } + + + @Test + @DisplayName("GET /apikeys/result redirects if response is null") + void showGeneratedApiKey_redirectsWhenResponseIsNull() throws Exception { + mvc.perform(get("/apikeys/result")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/apikeys/apikeyform")); + } + + @Test + @DisplayName("POST /apikeys/generate redirects to result") + void generateApiKey() throws Exception { + when(issuedApiKeyService.generateRawApiKey()).thenReturn("fake-key"); + when(issuedApiKeyService.hashedApiKey("fake-key")).thenReturn("hashed-key"); + when(issuedApiKeyService.apiKeyExists("hashed-key")).thenReturn(false); + + mvc.perform(postWithCsrf("/apikeys/generate") + .param("apiKeyName", "test-key")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/apikeys/result")); + } + + @Test + void showGeneratedApiKey_rendersViewWithResponse() throws Exception { + ResponseApiKeyOnce response = new ResponseApiKeyOnce("API-KEY", "key", "store safely"); + + mvc.perform(get("/apikeys/result") + .flashAttr("response", response)) + .andExpect(status().isOk()) + .andExpect(view().name("apikeys/result")) + .andExpect(model().attributeExists("response")); + } + + @Test + void generateApiKey_retriesUntilUnique() throws Exception { + when(issuedApiKeyService.generateRawApiKey()) + .thenReturn("duplicate", "unique"); + when(issuedApiKeyService.hashedApiKey(any())) + .thenReturn("hashed-duplicate", "hashed-unique"); + when(issuedApiKeyService.apiKeyExists("hashed-duplicate")).thenReturn(true); + when(issuedApiKeyService.apiKeyExists("hashed-unique")).thenReturn(false); + + mvc.perform(postWithCsrf("/apikeys/generate") + .param("apiKeyName", "retry-test")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/apikeys/result")); + + verify(issuedApiKeyService, Mockito.times(2)).generateRawApiKey(); + } + + + @Test + @DisplayName("GET /apikeys/mykeys displays user keys") + void showMyKeys() throws Exception { + + var account = new Account(); + account.setUsername("test-user"); + account.setGoogleOidc2Id("google-oidc2"); + account.setId(1L); + var link = new GoogleAccountApiKeyLink(); + link.setAccount(account); + link.setId(1L); + + when(issuedApiKeyService.findByGoogleOidc2Id("google-oidc2")).thenReturn(Optional.of(link)); + when(issuedApiKeyService.allApiKeys("google-oidc2")).thenReturn(new ResponseApiKeyList(List.of())); + + + mvc.perform(get("/apikeys/mykeys") + .with(SecurityMockMvcRequestPostProcessors.authentication(mockOAuth2Auth("google-oidc2")))) + .andExpect(status().isOk()) + .andExpect(view().name("apikeys/mykeys")) + .andExpect(model().attributeExists("keys")) + .andExpect(model().attributeExists("account")); + Mockito.verify(issuedApiKeyService).findByGoogleOidc2Id("google-oidc2"); + Mockito.verify(issuedApiKeyService).allApiKeys("google-oidc2"); + + + } + @Test + @DisplayName("POST /apikeys/mykeys/revoke revokes a key") + void revokeKey() throws Exception { + String fakeReferenceId = UUID.randomUUID().toString(); + UUID uuid = UUID.fromString(fakeReferenceId); + String userName = "testuser"; + + when(issuedApiKeyService.isValidUuid(uuid, userName)).thenReturn(false); + + mvc.perform(postWithCsrf("/apikeys/mykeys/revoke").with(SecurityMockMvcRequestPostProcessors.authentication(mockOAuth2Auth(userName))) + .param("referenceId", fakeReferenceId)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/apikeys/mykeys")); + verify(issuedApiKeyService, Mockito.times(1)).isValidUuid(uuid, userName); + verify(issuedApiKeyService, never()).updateApiKey(any(UpdateApiKey.class)); + } + + @Test + @DisplayName("POST /apikeys/mykeys/revoke revokes a key if valid") + void revokeKey_valid() throws Exception { + String fakeReferenceId = UUID.randomUUID().toString(); + UUID uuid = UUID.fromString(fakeReferenceId); + String userName = "testuser3"; + + when(issuedApiKeyService.isValidUuid(uuid, userName)).thenReturn(true); + + mvc.perform(postWithCsrf("/apikeys/mykeys/revoke").with(SecurityMockMvcRequestPostProcessors.authentication(mockOAuth2Auth(userName))) + .param("referenceId", fakeReferenceId) + .principal(mockOAuth2Auth(userName))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/apikeys/mykeys")); + } + + + + + @Test + @DisplayName("POST /apikeys/mykeys/nameupdate updates key name") + void updateKeyName() throws Exception { + String fakeReferenceId = UUID.randomUUID().toString(); + UUID uuid = UUID.fromString(fakeReferenceId); + String userName = "testuser5"; + String newKeyName = "new-key"; + + when(issuedApiKeyService.isValidUuid(uuid, userName)).thenReturn(true); + + mvc.perform(postWithCsrf("/apikeys/mykeys/nameupdate").with(SecurityMockMvcRequestPostProcessors.authentication(mockOAuth2Auth(userName))) + .param("referenceId", fakeReferenceId) + .param("apiKeyName", newKeyName)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/apikeys/mykeys")); + verify(issuedApiKeyService, Mockito.times(1)).isValidUuid(uuid, userName); + verify(issuedApiKeyService, times(1)).updateApiKey(any(UpdateApiKey.class)); + } +} diff --git a/src/test/java/org/storkforge/barkr/api/controller/ApiPostControllerTest.java b/src/test/java/org/storkforge/barkr/api/controller/ApiPostControllerTest.java index f3ad0b3..1dda0d6 100644 --- a/src/test/java/org/storkforge/barkr/api/controller/ApiPostControllerTest.java +++ b/src/test/java/org/storkforge/barkr/api/controller/ApiPostControllerTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -33,6 +34,7 @@ class ApiPostControllerTest { private PostService service; @Test + @WithMockUser @DisplayName("Finds all posts from service") void postsReturnsAllPosts() throws Exception { ResponseAccount mockAccount = new ResponseAccount(1L, "testAccount", LocalDateTime.now(), "beagle", new byte[0]); @@ -57,6 +59,7 @@ void postsReturnsAllPosts() throws Exception { } @Test + @WithMockUser @DisplayName("Finds a post from service") void findPost() throws Exception { ResponseAccount mockAccount = new ResponseAccount(1L, "testAccount", LocalDateTime.now(), "beagle", new byte[0]); @@ -74,6 +77,7 @@ void findPost() throws Exception { @Test + @WithMockUser @DisplayName("Handles error for nonexistent id") void handlesErrorForNonexistentId() throws Exception { when(service.findById(1L)).thenThrow(new PostNotFound("Post with id: 1 was not found")); @@ -83,4 +87,3 @@ void handlesErrorForNonexistentId() throws Exception { .andExpect(status().isNotFound()); } } - diff --git a/src/test/java/org/storkforge/barkr/domain/IssuedApiKeyServiceTest.java b/src/test/java/org/storkforge/barkr/domain/IssuedApiKeyServiceTest.java new file mode 100644 index 0000000..3675961 --- /dev/null +++ b/src/test/java/org/storkforge/barkr/domain/IssuedApiKeyServiceTest.java @@ -0,0 +1,53 @@ +package org.storkforge.barkr.domain; + + +import com.redis.testcontainers.RedisContainer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.storkforge.barkr.infrastructure.persistence.GoogleAccountApiKeyLinkRepository; +import org.storkforge.barkr.infrastructure.persistence.IssuedApiKeyRepository; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = {"barkr.api.secret=test-value"}) +class IssuedApiKeyServiceTest { + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")); + + @Container + @ServiceConnection + static RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:latest")); + + @MockitoBean + private IssuedApiKeyRepository issuedApiKeyRepository; + + @Value("${barkr.api.secret}") + private String secretKey; + + @MockitoBean + private GoogleAccountApiKeyLinkRepository googleAccountApiKeyLinkRepository; + + @Autowired + private IssuedApiKeyService service; + + + @Test + @DisplayName("testValue injection is working") + void testValueInjectionIsWorking() { + System.out.println(secretKey); + assertThat(secretKey).isEqualTo("test-value"); + + } + +} diff --git a/src/test/java/org/storkforge/barkr/graphql/AccountResolverTest.java b/src/test/java/org/storkforge/barkr/graphql/AccountResolverTest.java index 6e97859..b6562e2 100644 --- a/src/test/java/org/storkforge/barkr/graphql/AccountResolverTest.java +++ b/src/test/java/org/storkforge/barkr/graphql/AccountResolverTest.java @@ -10,6 +10,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.graphql.test.tester.GraphQlTester; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.storkforge.barkr.domain.AccountService; import org.storkforge.barkr.dto.accountDto.ResponseAccount; @@ -33,6 +34,7 @@ class AccountResolverTest { private AccountService accountService; @Test + @WithMockUser void testGetAccountById() { ResponseAccount account = new ResponseAccount(10L, "testAccount", LocalDateTime.now(), "beagle", new byte[0]); @@ -52,6 +54,7 @@ void testGetAccountById() { } @Test + @WithMockUser void testGetAllAccounts() { ResponseAccount account1 = new ResponseAccount(1L, "accountOne", LocalDateTime.now(), "beagle", new byte[0]); ResponseAccount account2 = new ResponseAccount(2L, "accountTwo", LocalDateTime.now(), "beagle", new byte[0]); @@ -78,6 +81,7 @@ void testGetAllAccounts() { @Test @DisplayName("Handles error for nonexistent id") + @WithMockUser void handlesErrorForNonexistentId() { when(accountService.findById(1L)).thenThrow(new AccountNotFound("Account with id: 1 was not found")); diff --git a/src/test/java/org/storkforge/barkr/graphql/PostResolverTest.java b/src/test/java/org/storkforge/barkr/graphql/PostResolverTest.java index a6b1ed6..ac73de9 100644 --- a/src/test/java/org/storkforge/barkr/graphql/PostResolverTest.java +++ b/src/test/java/org/storkforge/barkr/graphql/PostResolverTest.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.graphql.test.tester.GraphQlTester; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.storkforge.barkr.domain.AccountService; import org.storkforge.barkr.domain.PostService; @@ -35,6 +36,7 @@ class PostResolverTest { private AccountService accountService; @Test + @WithMockUser void testGetPostById() { ResponseAccount account = new ResponseAccount(10L, "testAccount", LocalDateTime.now(), "beagle", new byte[0]); ResponsePost post = new ResponsePost(1L, "Test content", account, LocalDateTime.now()); @@ -61,6 +63,7 @@ void testGetPostById() { } @Test + @WithMockUser void testGetAllPosts() { ResponseAccount account = new ResponseAccount(10L, "testAccount", LocalDateTime.now(), "beagle", new byte[0]); ResponsePost post1 = new ResponsePost(1L, "First post", account, LocalDateTime.now()); @@ -86,6 +89,7 @@ void testGetAllPosts() { } @Test + @WithMockUser @DisplayName("Handles error for nonexistent id") void handlesErrorForNonexistentId() { when(postService.findById(1L)).thenThrow(new PostNotFound("Post with id: 1 was not found")); diff --git a/src/test/java/org/storkforge/barkr/web/controller/WebControllerImageRouteTest.java b/src/test/java/org/storkforge/barkr/web/controller/WebControllerImageRouteTest.java index 8d0fb19..c25bb99 100644 --- a/src/test/java/org/storkforge/barkr/web/controller/WebControllerImageRouteTest.java +++ b/src/test/java/org/storkforge/barkr/web/controller/WebControllerImageRouteTest.java @@ -6,6 +6,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.HttpHeaders; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.storkforge.barkr.domain.AccountService; @@ -17,6 +18,7 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -37,6 +39,7 @@ class WebControllerImageRouteTest { @Test @DisplayName("Returns account image if present") + @WithMockUser void returnsAccountImageIfPresent() throws Exception { byte[] image = "test-image".getBytes(); when(accountService.getAccountImage(1L)).thenReturn(image); @@ -48,6 +51,7 @@ void returnsAccountImageIfPresent() throws Exception { } @Test + @WithMockUser @DisplayName("Returns fallback image if account image is null") void returnsFallbackImageIfNull() throws Exception { byte[] fallback; @@ -65,39 +69,43 @@ void returnsFallbackImageIfNull() throws Exception { @Test @DisplayName("Redirects with error if file is empty") + @WithMockUser void fileIsEmpty() throws Exception { MockMultipartFile emptyFile = new MockMultipartFile("file", "image.png", "image/png", new byte[0]); - mockMvc.perform(multipart("/account/1/upload").file(emptyFile)) + mockMvc.perform(multipart("/account/1/upload").file(emptyFile).with(csrf())) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/")) .andExpect(flash().attribute("error", "File is empty")); } @Test + @WithMockUser @DisplayName("Redirects with error if file is not an image") void fileIsNotImage() throws Exception { MockMultipartFile file = new MockMultipartFile("file", "file.txt", "text/plain", "some-text".getBytes()); - mockMvc.perform(multipart("/account/1/upload").file(file)) + mockMvc.perform(multipart("/account/1/upload").file(file).with(csrf())) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/")) .andExpect(flash().attribute("error", "File is not an image")); } @Test + @WithMockUser @DisplayName("Redirects with error if file is too large") void fileIsTooLarge() throws Exception { byte[] large = new byte[6 * 1024 * 1024]; // 6MB MockMultipartFile file = new MockMultipartFile("file", "big.png", "image/png", large); - mockMvc.perform(multipart("/account/1/upload").file(file)) + mockMvc.perform(multipart("/account/1/upload").file(file).with(csrf())) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/")) .andExpect(flash().attribute("error", "File is too large")); } @Test + @WithMockUser @DisplayName("Redirects to root after successful image upload") void uploadSuccess() throws Exception { byte[] img = "image-content".getBytes(); @@ -105,7 +113,7 @@ void uploadSuccess() throws Exception { doNothing().when(accountService).updateImage(1L, img); - mockMvc.perform(multipart("/account/1/upload").file(file)) + mockMvc.perform(multipart("/account/1/upload").file(file).with(csrf())) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/")); } diff --git a/src/test/java/org/storkforge/barkr/web/controller/WebControllerTest.java b/src/test/java/org/storkforge/barkr/web/controller/WebControllerTest.java index e77bdd7..5b2f689 100644 --- a/src/test/java/org/storkforge/barkr/web/controller/WebControllerTest.java +++ b/src/test/java/org/storkforge/barkr/web/controller/WebControllerTest.java @@ -7,16 +7,24 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.data.annotation.Transient; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; +import org.storkforge.barkr.api.controller.ApiKeyController; +import org.storkforge.barkr.domain.IssuedApiKeyService; import org.storkforge.barkr.domain.entity.Account; +import org.storkforge.barkr.domain.entity.GoogleAccountApiKeyLink; import org.storkforge.barkr.domain.entity.Post; +import org.storkforge.barkr.domain.roles.BarkrRole; import org.storkforge.barkr.infrastructure.persistence.AccountRepository; import org.storkforge.barkr.infrastructure.persistence.PostRepository; import org.testcontainers.containers.PostgreSQLContainer; @@ -25,8 +33,10 @@ import org.testcontainers.utility.DockerImageName; import java.io.IOException; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -46,9 +56,19 @@ class WebControllerTest { @ServiceConnection static RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:latest")); + @Mock + private IssuedApiKeyService issuedApiKeyService; + + @InjectMocks + private ApiKeyController apiKeyController; + + @Autowired private AccountRepository accountRepository; + @Mock + private GoogleAccountApiKeyLink googleAccountApiKeyLink; + @Autowired private PostRepository postRepository; @@ -65,19 +85,40 @@ void clearCache() { ); } + + @Nested class IndexRouteTest { @Test + @WithMockUser @DisplayName("Can view all posts") void viewAllPostsPage() throws IOException { Account mockAccount = new Account(); + GoogleAccountApiKeyLink mockKeyLink = new GoogleAccountApiKeyLink(); + mockAccount.setUsername("mockAccount"); mockAccount.setBreed("husky"); + mockAccount.setGoogleOidc2Id("6"); + + mockAccount.setImage(null); + mockAccount.setRoles(new HashSet<>(Set.of(BarkrRole.USER, BarkrRole.PREMIUM))); + + mockKeyLink.setAccount(mockAccount); + mockAccount.setGoogleAccountApiKeyLink(mockKeyLink); Account mockAccount2 = new Account(); + GoogleAccountApiKeyLink mockKeyLink2 = new GoogleAccountApiKeyLink(); + mockAccount2.setUsername("mockAccount2"); mockAccount2.setBreed("beagle"); + mockAccount2.setGoogleOidc2Id("7"); + mockAccount2.setImage(null); + + mockAccount2.setRoles(new HashSet<>(Set.of(BarkrRole.USER, BarkrRole.PREMIUM))); + mockKeyLink2.setAccount(mockAccount2); + mockAccount2.setGoogleAccountApiKeyLink(mockKeyLink2); + accountRepository.saveAll(List.of(mockAccount, mockAccount2)); Post mockPost = new Post(); @@ -100,10 +141,11 @@ void viewAllPostsPage() throws IOException { } @Test + @WithMockUser(authorities = {"ROLE_USER", "ROLE_PREMIUM"}) @DisplayName("Verify add post form exist") void verifyAddPostFormExist() throws IOException { HtmlPage page = htmlClient.getPage("/"); - HtmlForm form = page.getForms().getFirst(); + HtmlForm form = page.getFormByName("index-first-form"); HtmlTextArea contentInput = form.getTextAreaByName("content"); assertAll( @@ -116,11 +158,12 @@ void verifyAddPostFormExist() throws IOException { } @Test + @WithMockUser @DisplayName("Can submit the add post form") void verifyAddPostFormSubmitted() throws IOException { HtmlPage page = htmlClient.getPage("/"); - HtmlForm form = page.getForms().getFirst(); + HtmlForm form = page.getFormByName("index-first-form"); HtmlTextArea contentInput = form.getTextAreaByName("content"); HtmlButton submitButton = (HtmlButton) form.getElementsByTagName("button").getFirst(); @@ -136,11 +179,13 @@ void verifyAddPostFormSubmitted() throws IOException { } @Test + @WithMockUser @DisplayName("Redirects the user on empty form value") void redirectOnEmptyForm() throws IOException { HtmlPage page = htmlClient.getPage("/"); - HtmlForm form = page.getForms().getFirst(); + + HtmlForm form = page.getFormByName("index-first-form"); HtmlButton submitButton = (HtmlButton) form.getElementsByTagName("button").getFirst(); HtmlPage resultPage = submitButton.click(); @@ -149,11 +194,12 @@ void redirectOnEmptyForm() throws IOException { } @Test + @WithMockUser @DisplayName("Redirects the user if value is greater than 255 characters") void redirectOnTooManyCharactersInput() throws IOException { HtmlPage page = htmlClient.getPage("/"); - HtmlForm form = page.getForms().getFirst(); + HtmlForm form = page.getFormByName("index-first-form"); HtmlTextArea contentInput = form.getTextAreaByName("content"); HtmlButton submitButton = (HtmlButton) form.getElementsByTagName("button").getFirst(); @@ -169,15 +215,28 @@ void redirectOnTooManyCharactersInput() throws IOException { @Nested class ProfileRouteTest { @Test + @WithMockUser @DisplayName("Can view profile page") void viewProfilePage() throws IOException { Account mockAccount = new Account(); + GoogleAccountApiKeyLink mockKeyLink = new GoogleAccountApiKeyLink(); mockAccount.setUsername("mockAccount"); mockAccount.setBreed("husky"); + mockAccount.setGoogleOidc2Id("4"); + mockAccount.setImage(null); + mockAccount.setRoles(new HashSet<>(Set.of(BarkrRole.USER, BarkrRole.PREMIUM))); + mockKeyLink.setAccount(mockAccount); + mockAccount.setGoogleAccountApiKeyLink(mockKeyLink); Account mockAccount2 = new Account(); + GoogleAccountApiKeyLink mockKeyLink2 = new GoogleAccountApiKeyLink(); mockAccount2.setUsername("mockAccount2"); mockAccount2.setBreed("beagle"); + mockAccount2.setGoogleOidc2Id("5"); + mockAccount2.setImage(null); + mockAccount2.setRoles(new HashSet<>(Set.of(BarkrRole.USER, BarkrRole.PREMIUM))); + mockKeyLink2.setAccount(mockAccount2); + mockAccount2.setGoogleAccountApiKeyLink(mockKeyLink2); accountRepository.saveAll(List.of(mockAccount, mockAccount2)); @@ -191,6 +250,8 @@ void viewProfilePage() throws IOException { postRepository.saveAll(List.of(mockPost, mockPost2)); HtmlPage page = htmlClient.getPage("/mockAccount"); + + mockAccount2.setRoles(Set.of()); String pageContent = page.asNormalizedText(); assertAll( @@ -201,6 +262,7 @@ void viewProfilePage() throws IOException { } @Test + @WithMockUser @DisplayName("Redirects the user on nonexistent account") void redirectsOnNonexistentAccount() throws IOException { HtmlPage page = htmlClient.getPage("/nonExistent");