This commit is contained in:
2026-04-27 22:36:44 +09:30
parent 86b6d6c0b9
commit 2d03f3a7f4
58 changed files with 4376 additions and 62 deletions
+162
View File
@@ -0,0 +1,162 @@
import org.gradle.api.tasks.testing.logging.TestLogEvent
import org.jooq.meta.jaxb.Property
plugins {
id("org.springframework.boot") version "3.2.5"
id("io.spring.dependency-management") version "1.1.4"
id("org.openapi.generator") version "7.3.0"
id("nu.studer.jooq") version "9.0"
id("java")
}
group = "net.moustos"
version = "0.1.0"
repositories {
mavenCentral()
}
dependencies {
// Spring Boot
implementation("org.springframework.boot:spring-boot-starter-web:3.2.5")
implementation("org.springframework.boot:spring-boot-starter-data-jpa:3.2.5")
implementation("org.springframework.boot:spring-boot-starter-security:3.2.5")
implementation("org.springframework.boot:spring-boot-starter-validation:3.2.5")
developmentOnly("org.springframework.boot:spring-boot-devtools:3.2.5")
// Database
implementation("org.postgresql:postgresql:42.7.3")
implementation("org.flywaydb:flyway-core:9.22.3")
implementation("org.flywaydb:flyway-database-postgresql:9.22.3")
// jOOQ
implementation("org.jooq:jooq:3.19.8")
implementation("org.jooq:jooq-meta:3.19.8")
implementation("org.jooq:jooq-codegen:3.19.8")
jooqGenerator("org.postgresql:postgresql:42.7.3")
// Security & JWT
implementation("com.auth0:java-jwt:4.4.0")
implementation("at.fageorgetown:jbcrypt:0.9.1")
implementation("org.springframework.security:spring-security-crypto:6.2.3")
// OpenAPI & Swagger
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0")
implementation("jakarta.annotation:jakarta.annotation-api:2.1.1")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1")
// Logging
implementation("ch.qos.logback:logback-core:1.5.0")
implementation("ch.qos.logback:logback-classic:1.5.0")
implementation("net.logstash.logback:logstash-logback-encoder:7.4")
// Utilities
implementation("org.projectlombok:lombok:1.18.30")
annotationProcessor("org.projectlombok:lombok:1.18.30")
implementation("org.apache.commons:commons-lang3:3.14.0")
implementation("com.google.guava:guava:33.1.0-jre")
// Testing
testImplementation("org.springframework.boot:spring-boot-starter-test:3.2.5")
testImplementation("org.springframework.security:spring-security-test:6.2.3")
testImplementation("org.testcontainers:testcontainers:1.19.7")
testImplementation("org.testcontainers:postgresql:1.19.7")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.mockito:mockito-core:5.7.1")
testImplementation("org.mockito:mockito-junit-jupiter:5.7.1")
testImplementation("io.rest-assured:rest-assured:5.4.0")
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
application {
mainClass.set("net.moustos.mtgsearch.MtgSearchApplication")
}
springBoot {
buildInfo()
}
// OpenAPI Generator Configuration
openApiGenerate {
generatorName.set("spring")
inputSpec.set("$projectDir/openapi/api.yaml")
outputDir.set("$projectDir/build/generated")
apiPackage.set("net.moustos.mtgsearch.api")
modelPackage.set("net.moustos.mtgsearch.model.api")
globalProperties.set(mapOf(
"apis" to "true",
"models" to "true"
))
configOptions.set(mapOf(
"delegatePattern" to "true",
"title" to "MTG Search API",
"interfaceOnly" to "false",
"skipDefaultInterface" to "false",
"useSpringBoot3" to "true",
"useJakartaEe" to "true"
))
}
// jOOQ Configuration
jooq {
configurations {
create("main") {
jooqConfiguration.apply {
jdbc.apply {
driver = "org.postgresql.Driver"
url = "jdbc:postgresql://localhost:5432/mtgsearch"
user = "postgres"
password = "postgres"
}
generator.apply {
name = "org.jooq.codegen.JavaGenerator"
database.apply {
name = "org.jooq.meta.postgres.PostgresDatabase"
inputSchema = "public"
}
target.apply {
packageName = "net.moustos.mtgsearch.jooq.generated"
directory = "$projectDir/build/generated/jooq"
}
}
}
}
}
}
tasks.register("generateJooqCode") {
dependsOn("jooqCodegen")
}
sourceSets {
main {
java {
srcDirs(
"src/main/java",
"$buildDir/generated/src/main/java",
"$buildDir/generated/jooq"
)
}
}
}
tasks.compileJava {
dependsOn("openApiGenerate", "generateJooqCode")
}
tasks.test {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
showStandardStreams = false
}
}
tasks.named("build") {
dependsOn("openApiGenerate", "generateJooqCode")
}
+162
View File
@@ -0,0 +1,162 @@
openapi: 3.0.0
info:
title: MTG Search API
version: 0.1.0
description: Magic The Gathering Card Search REST API
contact:
name: API Support
url: https://github.com/example/mtg-search
license:
name: MIT
servers:
- url: http://localhost:8080
description: Development server
- url: https://api.mtgsearch.example.com
description: Production server
paths:
/api/v1/auth/register:
post:
tags:
- Authentication
summary: Register a new user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
responses:
'201':
description: User registered successfully
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'400':
description: Invalid input
'409':
description: User already exists
/api/v1/auth/login:
post:
tags:
- Authentication
summary: Login user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
description: Login successful
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
'401':
description: Invalid credentials
'500':
description: Server error
/api/v1/auth/health:
get:
tags:
- Health
summary: Health check
responses:
'200':
description: Service is healthy
content:
application/json:
schema:
$ref: '#/components/schemas/HealthResponse'
components:
schemas:
RegisterRequest:
type: object
required:
- username
- email
- password
properties:
username:
type: string
minLength: 3
maxLength: 100
example: john_doe
email:
type: string
format: email
example: john@example.com
password:
type: string
format: password
minLength: 8
example: SecurePassword123!
LoginRequest:
type: object
required:
- username
- password
properties:
username:
type: string
example: john_doe
password:
type: string
format: password
example: SecurePassword123!
UserResponse:
type: object
properties:
id:
type: integer
format: int64
username:
type: string
email:
type: string
message:
type: string
error:
type: string
LoginResponse:
type: object
properties:
token:
type: string
description: JWT token for authentication
id:
type: integer
format: int64
username:
type: string
email:
type: string
error:
type: string
HealthResponse:
type: object
properties:
status:
type: string
enum:
- ok
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []
@@ -0,0 +1,17 @@
package net.moustos.mtgsearch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
/**
* Main Spring Boot Application entry point for MTG Search
*/
@SpringBootApplication
@ComponentScan(basePackages = {"net.moustos.mtgsearch"})
public class MtgSearchApplication {
public static void main(String[] args) {
SpringApplication.run(MtgSearchApplication.class, args);
}
}
@@ -0,0 +1,37 @@
package net.moustos.mtgsearch.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* General application configuration
*/
@Configuration
public class AppConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:5173"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Accept"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -0,0 +1,69 @@
package net.moustos.mtgsearch.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import net.moustos.mtgsearch.security.JwtAuthenticationFilter;
import net.moustos.mtgsearch.service.UserDetailsServiceImpl;
/**
* Spring Security configuration
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
private final PasswordEncoder passwordEncoder;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(UserDetailsServiceImpl userDetailsService, PasswordEncoder passwordEncoder,
JwtAuthenticationFilter jwtAuthenticationFilter) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder
.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder);
return authenticationManagerBuilder.build();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.disable())
.csrf(csrf -> csrf.disable())
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Unauthorized\"}");
})
)
.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers("/swagger-ui/**", "/javadoc/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/health", "/health/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@@ -0,0 +1,22 @@
package net.moustos.mtgsearch.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC configuration for serving static resources
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(org.springframework.http.CacheControl.maxAge(365, java.util.concurrent.TimeUnit.DAYS));
registry.addResourceHandler("/index.html")
.addResourceLocations("classpath:/static/index.html");
}
}
@@ -0,0 +1,130 @@
package net.moustos.mtgsearch.controller;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import net.moustos.mtgsearch.model.User;
import net.moustos.mtgsearch.service.AuthService;
import java.util.Map;
/**
* Authentication controller for login and registration endpoints
*/
@RestController
@RequestMapping("/api/v1/auth")
@CrossOrigin(origins = {"http://localhost:3000", "http://localhost:5173"})
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
/**
* Register a new user
*/
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
try {
User user = authService.register(request.getUsername(), request.getEmail(), request.getPassword());
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
"id", user.getId(),
"username", user.getUsername(),
"email", user.getEmail(),
"message", "User registered successfully"
));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of(
"error", e.getMessage()
));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
"error", "Registration failed"
));
}
}
/**
* Login user and return JWT token
*/
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
String token = authService.authenticate(request.getUsername(), request.getPassword());
User user = authService.getUserByUsername(request.getUsername()).orElseThrow();
return ResponseEntity.ok(Map.of(
"token", token,
"id", user.getId(),
"username", user.getUsername(),
"email", user.getEmail()
));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of(
"error", e.getMessage()
));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
"error", "Login failed"
));
}
}
/**
* Health check endpoint
*/
@GetMapping("/health")
public ResponseEntity<Map<String, String>> health() {
return ResponseEntity.ok(Map.of("status", "ok"));
}
// DTOs
public static class RegisterRequest {
public String username;
public String email;
public String password;
public RegisterRequest() {
}
public RegisterRequest(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
}
public String getUsername() {
return username;
}
public String getEmail() {
return email;
}
public String getPassword() {
return password;
}
}
public static class LoginRequest {
public String username;
public String password;
public LoginRequest() {
}
public LoginRequest(String username, String password) {
this.username = username;
this.password = password;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
}
}
@@ -0,0 +1,55 @@
package net.moustos.mtgsearch.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* User entity for authentication
*/
@Entity
@Table(name = "users")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String username;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(nullable = false, length = 255)
private String password;
@Column(nullable = false)
private Boolean active;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
active = true;
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,18 @@
package net.moustos.mtgsearch.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import net.moustos.mtgsearch.model.User;
import java.util.Optional;
/**
* User repository for database operations
*/
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
@@ -0,0 +1,57 @@
package net.moustos.mtgsearch.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import net.moustos.mtgsearch.service.UserDetailsServiceImpl;
import java.io.IOException;
/**
* JWT Authentication filter to validate tokens on each request
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsServiceImpl userDetailsService) {
this.jwtTokenProvider = jwtTokenProvider;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = extractJwtFromRequest(request);
if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
String username = jwtTokenProvider.validateTokenAndGetUsername(jwt);
var userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
}
filterChain.doFilter(request, response);
}
private String extractJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
@@ -0,0 +1,83 @@
package net.moustos.mtgsearch.security;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
/**
* JWT Token provider for generating and validating JWT tokens
*/
@Component
public class JwtTokenProvider {
@Value("${app.jwt.secret:your-secret-key-change-in-production}")
private String jwtSecret;
@Value("${app.jwt.expiration:86400}")
private long jwtExpiration;
private static final String CLAIM_USER_ID = "userId";
private static final String CLAIM_USERNAME = "username";
private static final String CLAIM_EMAIL = "email";
/**
* Generate a JWT token for a user
*/
public String generateToken(long userId, String username, String email) {
Instant now = Instant.now();
Instant expiresAt = now.plus(jwtExpiration, ChronoUnit.SECONDS);
return JWT.create()
.withClaim(CLAIM_USER_ID, userId)
.withClaim(CLAIM_USERNAME, username)
.withClaim(CLAIM_EMAIL, email)
.withIssuedAt(now)
.withExpiresAt(expiresAt)
.withIssuer("mtg-search")
.sign(Algorithm.HMAC256(jwtSecret));
}
/**
* Validate JWT token and extract user ID
*/
public long validateTokenAndGetUserId(String token) throws JWTVerificationException {
return JWT.require(Algorithm.HMAC256(jwtSecret))
.withIssuer("mtg-search")
.build()
.verify(token)
.getClaim(CLAIM_USER_ID)
.asLong();
}
/**
* Validate JWT token and extract username
*/
public String validateTokenAndGetUsername(String token) throws JWTVerificationException {
return JWT.require(Algorithm.HMAC256(jwtSecret))
.withIssuer("mtg-search")
.build()
.verify(token)
.getClaim(CLAIM_USERNAME)
.asString();
}
/**
* Validate JWT token
*/
public boolean validateToken(String token) {
try {
JWT.require(Algorithm.HMAC256(jwtSecret))
.withIssuer("mtg-search")
.build()
.verify(token);
return true;
} catch (JWTVerificationException e) {
return false;
}
}
}
@@ -0,0 +1,86 @@
package net.moustos.mtgsearch.service;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import net.moustos.mtgsearch.model.User;
import net.moustos.mtgsearch.repository.UserRepository;
import net.moustos.mtgsearch.security.JwtTokenProvider;
import java.util.Optional;
/**
* Authentication service for user login and registration
*/
@Service
@Transactional
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtTokenProvider = jwtTokenProvider;
}
/**
* Register a new user
*/
public User register(String username, String email, String password) {
if (userRepository.existsByUsername(username)) {
throw new IllegalArgumentException("Username already exists");
}
if (userRepository.existsByEmail(email)) {
throw new IllegalArgumentException("Email already exists");
}
User user = User.builder()
.username(username)
.email(email)
.password(passwordEncoder.encode(password))
.active(true)
.build();
return userRepository.save(user);
}
/**
* Authenticate user and return JWT token
*/
public String authenticate(String username, String password) {
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isEmpty()) {
throw new IllegalArgumentException("Invalid username or password");
}
User user = userOpt.get();
if (!user.getActive()) {
throw new IllegalArgumentException("User account is inactive");
}
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new IllegalArgumentException("Invalid username or password");
}
return jwtTokenProvider.generateToken(user.getId(), user.getUsername(), user.getEmail());
}
/**
* Get user by ID
*/
@Transactional(readOnly = true)
public Optional<User> getUserById(Long userId) {
return userRepository.findById(userId);
}
/**
* Get user by username
*/
@Transactional(readOnly = true)
public Optional<User> getUserByUsername(String username) {
return userRepository.findByUsername(username);
}
}
@@ -0,0 +1,40 @@
package net.moustos.mtgsearch.service;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import net.moustos.mtgsearch.repository.UserRepository;
import java.util.Collections;
/**
* User details service for Spring Security
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")))
.accountNonLocked(user.getActive())
.accountNonExpired(true)
.credentialsNonExpired(true)
.enabled(user.getActive())
.build();
}
}
@@ -0,0 +1,72 @@
spring:
application:
name: mtg-search
version: 0.1.0
datasource:
url: jdbc:postgresql://localhost:5432/mtgsearch
username: postgres
password: postgres
hikari:
maximum-pool-size: 10
minimum-idle: 2
idle-timeout: 300000
max-lifetime: 1200000
auto-commit: true
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate.format_sql: true
hibernate.jdbc.batch_size: 20
hibernate.order_inserts: true
hibernate.order_updates: true
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
sql-migration-prefix: V
sql-migration-suffix: .sql
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
app:
jwt:
secret: ${JWT_SECRET:your-secret-key-change-in-production}
expiration: ${JWT_EXPIRATION:86400}
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
enabled: true
operations-sorter: method
tags-sorter: alpha
server:
port: 8080
servlet:
context-path: /
compression:
enabled: true
min-response-size: 1024
logging:
level:
root: INFO
net.moustos.mtgsearch: DEBUG
org.springframework.web: INFO
org.springframework.security: DEBUG
config: classpath:logback-spring.xml
file:
name: logs/mtg-search.log
max-size: 100MB
max-history: 30
@@ -0,0 +1,16 @@
-- Initial schema setup for MTG Search
-- Users table for authentication
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for common queries
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_active ON users(active);
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_DIR" value="logs"/>
<property name="LOG_FILE_NAME" value="mtg-search"/>
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
<!-- Console appender -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<!-- Rolling file appender -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/${LOG_FILE_NAME}.log</file>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/${LOG_FILE_NAME}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- Error file appender -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/${LOG_FILE_NAME}-error.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/${LOG_FILE_NAME}-error-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- Root logger -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<!-- Application loggers -->
<logger name="net.moustos.mtgsearch" level="DEBUG"/>
<logger name="org.springframework" level="INFO"/>
<logger name="org.springframework.security" level="DEBUG"/>
<logger name="org.hibernate" level="WARN"/>
<logger name="org.flywaydb" level="INFO"/>
</configuration>
@@ -0,0 +1,119 @@
package net.moustos.mtgsearch;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
/**
* Integration tests for authentication API
*/
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(locations = "classpath:application-test.yml")
public class AuthControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@BeforeEach
public void setUp() {
// Clear database before each test
}
@Test
public void testUserRegistration() throws Exception {
String registerPayload = """
{
"username": "testuser",
"email": "test@example.com",
"password": "SecurePassword123!"
}
""";
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerPayload))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.username").value("testuser"))
.andExpect(jsonPath("$.email").value("test@example.com"));
}
@Test
public void testUserLogin() throws Exception {
// First register
String registerPayload = """
{
"username": "testuser",
"email": "test@example.com",
"password": "SecurePassword123!"
}
""";
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerPayload))
.andExpect(status().isCreated());
// Then login
String loginPayload = """
{
"username": "testuser",
"password": "SecurePassword123!"
}
""";
mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(loginPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").exists())
.andExpect(jsonPath("$.username").value("testuser"));
}
@Test
public void testLoginWithInvalidCredentials() throws Exception {
String loginPayload = """
{
"username": "nonexistent",
"password": "WrongPassword123!"
}
""";
mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(loginPayload))
.andExpect(status().isUnauthorized());
}
@Test
public void testDuplicateUsernameRegistration() throws Exception {
String registerPayload = """
{
"username": "testuser",
"email": "test@example.com",
"password": "SecurePassword123!"
}
""";
// Register first user
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerPayload))
.andExpect(status().isCreated());
// Try to register with same username
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerPayload))
.andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,108 @@
package net.moustos.mtgsearch.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.TestPropertySource;
import net.moustos.mtgsearch.model.User;
import net.moustos.mtgsearch.repository.UserRepository;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for AuthService
*/
@SpringBootTest
@TestPropertySource(locations = "classpath:application-test.yml")
@DisplayName("AuthService Tests")
public class AuthServiceTest {
@Autowired
private AuthService authService;
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@BeforeEach
public void setUp() {
userRepository.deleteAll();
}
@Test
@DisplayName("Should register a new user successfully")
public void testRegisterUser() {
User user = authService.register("testuser", "test@example.com", "SecurePassword123!");
assertNotNull(user.getId());
assertEquals("testuser", user.getUsername());
assertEquals("test@example.com", user.getEmail());
assertTrue(user.getActive());
}
@Test
@DisplayName("Should throw exception for duplicate username")
public void testRegisterDuplicateUsername() {
authService.register("testuser", "test@example.com", "SecurePassword123!");
assertThrows(IllegalArgumentException.class, () ->
authService.register("testuser", "another@example.com", "SecurePassword123!")
);
}
@Test
@DisplayName("Should throw exception for duplicate email")
public void testRegisterDuplicateEmail() {
authService.register("testuser", "test@example.com", "SecurePassword123!");
assertThrows(IllegalArgumentException.class, () ->
authService.register("anotheruser", "test@example.com", "SecurePassword123!")
);
}
@Test
@DisplayName("Should authenticate user successfully")
public void testAuthenticateUser() {
authService.register("testuser", "test@example.com", "SecurePassword123!");
String token = authService.authenticate("testuser", "SecurePassword123!");
assertNotNull(token);
assertFalse(token.isEmpty());
}
@Test
@DisplayName("Should throw exception for invalid username")
public void testAuthenticateWithInvalidUsername() {
assertThrows(IllegalArgumentException.class, () ->
authService.authenticate("nonexistent", "SomePassword123!")
);
}
@Test
@DisplayName("Should throw exception for invalid password")
public void testAuthenticateWithInvalidPassword() {
authService.register("testuser", "test@example.com", "SecurePassword123!");
assertThrows(IllegalArgumentException.class, () ->
authService.authenticate("testuser", "WrongPassword123!")
);
}
@Test
@DisplayName("Should retrieve user by ID")
public void testGetUserById() {
User registeredUser = authService.register("testuser", "test@example.com", "SecurePassword123!");
var retrievedUser = authService.getUserById(registeredUser.getId());
assertTrue(retrievedUser.isPresent());
assertEquals(registeredUser.getId(), retrievedUser.get().getId());
}
@Test
@DisplayName("Should retrieve user by username")
public void testGetUserByUsername() {
authService.register("testuser", "test@example.com", "SecurePassword123!");
var retrievedUser = authService.getUserByUsername("testuser");
assertTrue(retrievedUser.isPresent());
assertEquals("testuser", retrievedUser.get().getUsername());
}
}
@@ -0,0 +1,88 @@
package net.moustos.mtgsearch.repository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import net.moustos.mtgsearch.model.User;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for UserRepository
*/
@SpringBootTest
@TestPropertySource(locations = "classpath:application-test.yml")
public class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@BeforeEach
public void setUp() {
userRepository.deleteAll();
}
@Test
public void testFindByUsername() {
User user = User.builder()
.username("testuser")
.email("test@example.com")
.password("hashedpassword")
.active(true)
.build();
userRepository.save(user);
var found = userRepository.findByUsername("testuser");
assertTrue(found.isPresent());
assertEquals("testuser", found.get().getUsername());
}
@Test
public void testFindByEmail() {
User user = User.builder()
.username("testuser")
.email("test@example.com")
.password("hashedpassword")
.active(true)
.build();
userRepository.save(user);
var found = userRepository.findByEmail("test@example.com");
assertTrue(found.isPresent());
assertEquals("test@example.com", found.get().getEmail());
}
@Test
public void testExistsByUsername() {
User user = User.builder()
.username("testuser")
.email("test@example.com")
.password("hashedpassword")
.active(true)
.build();
userRepository.save(user);
assertTrue(userRepository.existsByUsername("testuser"));
assertFalse(userRepository.existsByUsername("nonexistent"));
}
@Test
public void testExistsByEmail() {
User user = User.builder()
.username("testuser")
.email("test@example.com")
.password("hashedpassword")
.active(true)
.build();
userRepository.save(user);
assertTrue(userRepository.existsByEmail("test@example.com"));
assertFalse(userRepository.existsByEmail("nonexistent@example.com"));
}
}
@@ -0,0 +1,30 @@
spring:
profiles:
active: test
datasource:
url: jdbc:postgresql://localhost:5432/mtgsearch_test
username: postgres
password: postgres
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 5
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: create-drop
show-sql: false
flyway:
enabled: true
baseline-on-migrate: true
app:
jwt:
secret: test-secret-key
expiration: 3600
logging:
level:
root: WARN
net.moustos.mtgsearch: DEBUG