jaca security auth - working

This commit is contained in:
2025-12-03 10:55:35 -05:00
parent 7e1b33efdf
commit 85b00e9d99
17 changed files with 688 additions and 28 deletions

View File

@@ -3,15 +3,11 @@ package group.goforward.ballistic;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication
@EnableCaching
@ComponentScan("group.goforward.ballistic.controllers")
@ComponentScan("group.goforward.ballistic.repos")
@ComponentScan("group.goforward.ballistic.services")
@EntityScan(basePackages = "group.goforward.ballistic.model")
@EnableJpaRepositories(basePackages = "group.goforward.ballistic.repos")
public class BallisticApplication {

View File

@@ -0,0 +1,16 @@
package group.goforward.ballistic.configuration;
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;
// @Configuration
// public class PasswordConfig {
// @Bean
// public PasswordEncoder passwordEncoder() {
// // BCrypt default password
// return new BCryptPasswordEncoder();
// }
// }

View File

@@ -0,0 +1,54 @@
package group.goforward.ballistic.configuration;
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.configuration.AuthenticationConfiguration;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
// Auth endpoints always open
.requestMatchers("/api/auth/**").permitAll()
// Swagger / docs
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
// Health
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
// Public product endpoints
.requestMatchers("/api/products/gunbuilder/**").permitAll()
// Everything else (for now) also open we can tighten later
.anyRequest().permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt is a solid default for user passwords
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration configuration
) throws Exception {
return configuration.getAuthenticationManager();
}
}

View File

@@ -0,0 +1,102 @@
package group.goforward.ballistic.controllers;
import group.goforward.ballistic.model.User;
import group.goforward.ballistic.repos.UserRepository;
import group.goforward.ballistic.security.JwtService;
import group.goforward.ballistic.web.dto.auth.AuthResponse;
import group.goforward.ballistic.web.dto.auth.LoginRequest;
import group.goforward.ballistic.web.dto.auth.RegisterRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.time.OffsetDateTime;
import java.util.UUID;
@RestController
@RequestMapping("/api/auth")
@CrossOrigin
public class AuthController {
private final UserRepository users;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
public AuthController(
UserRepository users,
PasswordEncoder passwordEncoder,
JwtService jwtService
) {
this.users = users;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
String email = request.getEmail().trim().toLowerCase();
if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body("Email is already registered");
}
User user = new User();
// Let DB generate id
user.setUuid(UUID.randomUUID());
user.setEmail(email);
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
user.setDisplayName(request.getDisplayName());
user.setRole("USER");
user.setIsActive(true);
user.setCreatedAt(OffsetDateTime.now());
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
String token = jwtService.generateToken(user);
AuthResponse response = new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
String email = request.getEmail().trim().toLowerCase();
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElse(null);
if (user == null || !user.getIsActive()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
}
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
}
user.setLastLoginAt(OffsetDateTime.now());
user.incrementLoginCount();
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
String token = jwtService.generateToken(user);
AuthResponse response = new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
);
return ResponseEntity.ok(response);
}
}

View File

@@ -1,9 +1,6 @@
package group.goforward.ballistic.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import org.hibernate.annotations.ColumnDefault;
@@ -13,8 +10,9 @@ import java.util.UUID;
@Entity
@Table(name = "users")
public class User {
@Id
@NotNull
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Integer id;
@@ -42,7 +40,7 @@ public class User {
@NotNull
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive = false;
private boolean isActive = true;
@NotNull
@ColumnDefault("now()")
@@ -57,6 +55,29 @@ public class User {
@Column(name = "deleted_at")
private OffsetDateTime deletedAt;
// NEW FIELDS
@Column(name = "email_verified_at")
private OffsetDateTime emailVerifiedAt;
@Column(name = "verification_token", length = Integer.MAX_VALUE)
private String verificationToken;
@Column(name = "reset_password_token", length = Integer.MAX_VALUE)
private String resetPasswordToken;
@Column(name = "reset_password_expires_at")
private OffsetDateTime resetPasswordExpiresAt;
@Column(name = "last_login_at")
private OffsetDateTime lastLoginAt;
@ColumnDefault("0")
@Column(name = "login_count", nullable = false)
private Integer loginCount = 0;
// --- Getters / setters ---
public Integer getId() {
return id;
}
@@ -105,12 +126,12 @@ public class User {
this.role = role;
}
public Boolean getIsActive() {
public boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
public void setIsActive(boolean active) {
isActive = active;
}
public OffsetDateTime getCreatedAt() {
@@ -137,4 +158,65 @@ public class User {
this.deletedAt = deletedAt;
}
public OffsetDateTime getEmailVerifiedAt() {
return emailVerifiedAt;
}
public void setEmailVerifiedAt(OffsetDateTime emailVerifiedAt) {
this.emailVerifiedAt = emailVerifiedAt;
}
public String getVerificationToken() {
return verificationToken;
}
public void setVerificationToken(String verificationToken) {
this.verificationToken = verificationToken;
}
public String getResetPasswordToken() {
return resetPasswordToken;
}
public void setResetPasswordToken(String resetPasswordToken) {
this.resetPasswordToken = resetPasswordToken;
}
public OffsetDateTime getResetPasswordExpiresAt() {
return resetPasswordExpiresAt;
}
public void setResetPasswordExpiresAt(OffsetDateTime resetPasswordExpiresAt) {
this.resetPasswordExpiresAt = resetPasswordExpiresAt;
}
public OffsetDateTime getLastLoginAt() {
return lastLoginAt;
}
public void setLastLoginAt(OffsetDateTime lastLoginAt) {
this.lastLoginAt = lastLoginAt;
}
public Integer getLoginCount() {
return loginCount;
}
public void setLoginCount(Integer loginCount) {
this.loginCount = loginCount;
}
// convenience helpers
@Transient
public boolean isEmailVerified() {
return emailVerifiedAt != null;
}
public void incrementLoginCount() {
if (loginCount == null) {
loginCount = 0;
}
loginCount++;
}
}

View File

@@ -2,10 +2,15 @@ package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface UserRepository extends JpaRepository<User, Integer> {
Optional<User> findByEmail(String email);
Optional<User> findByEmailIgnoreCaseAndDeletedAtIsNull(String email);
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
Optional<User> findByUuid(UUID uuid);
}

View File

@@ -0,0 +1,59 @@
package group.goforward.ballistic.security;
import group.goforward.ballistic.model.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
public class CustomUserDetails implements UserDetails {
private final User user;
private final List<GrantedAuthority> authorities;
public CustomUserDetails(User user) {
this.user = user;
this.authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
}
public User getUser() {
return user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return user.getPasswordHash();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return user.getDeletedAt() == null;
}
@Override
public boolean isAccountNonLocked() {
return user.getIsActive();
}
@Override
public boolean isCredentialsNonExpired() {
return user.getDeletedAt() == null;
}
@Override
public boolean isEnabled() {
return user.getIsActive() && user.getDeletedAt() == null;
}
}

View File

@@ -0,0 +1,25 @@
package group.goforward.ballistic.security;
import group.goforward.ballistic.model.User;
import group.goforward.ballistic.repos.UserRepository;
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;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository users;
public CustomUserDetailsService(UserRepository users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new CustomUserDetails(user);
}
}

View File

@@ -0,0 +1,26 @@
package group.goforward.ballistic.security;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException, ServletException {
// Simple JSON 401 response
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Unauthorized\"}");
}
}

View File

@@ -0,0 +1,80 @@
package group.goforward.ballistic.security;
import group.goforward.ballistic.model.User;
import group.goforward.ballistic.repos.UserRepository;
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.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserRepository userRepository;
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
this.jwtService = jwtService;
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (!jwtService.isTokenValid(token)) {
filterChain.doFilter(request, response);
return;
}
UUID userUuid = jwtService.extractUserUuid(token);
if (userUuid == null || SecurityContextHolder.getContext().getAuthentication() != null) {
filterChain.doFilter(request, response);
return;
}
User user = userRepository.findByUuid(userUuid)
.orElse(null);
if (user == null || !user.getIsActive()) {
filterChain.doFilter(request, response);
return;
}
CustomUserDetails userDetails = new CustomUserDetails(user);
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,71 @@
package group.goforward.ballistic.security;
import group.goforward.ballistic.model.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
@Service
public class JwtService {
private final Key key;
private final long accessTokenMinutes;
public JwtService(
@Value("${security.jwt.secret}") String secret,
@Value("${security.jwt.access-token-minutes:60}") long accessTokenMinutes
) {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
this.accessTokenMinutes = accessTokenMinutes;
}
public String generateToken(User user) {
Instant now = Instant.now();
Instant expiry = now.plus(accessTokenMinutes, ChronoUnit.MINUTES);
return Jwts.builder()
.setSubject(user.getUuid().toString())
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiry))
.addClaims(Map.of(
"email", user.getEmail(),
"role", user.getRole(),
"displayName", user.getDisplayName()
))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public UUID extractUserUuid(String token) {
Claims claims = parseClaims(token);
return UUID.fromString(claims.getSubject());
}
public boolean isTokenValid(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException ex) {
return false;
}
}
private Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}

View File

@@ -0,0 +1,49 @@
package group.goforward.ballistic.web.dto.auth;
public class AuthResponse {
private String token;
private String email;
private String displayName;
private String role;
public AuthResponse() {}
public AuthResponse(String token, String email, String displayName, String role) {
this.token = token;
this.email = email;
this.displayName = displayName;
this.role = role;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
}

View File

@@ -0,0 +1,22 @@
package group.goforward.ballistic.web.dto.auth;
public class LoginRequest {
private String email;
private String password;
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -0,0 +1,31 @@
package group.goforward.ballistic.web.dto.auth;
public class RegisterRequest {
private String email;
private String password;
private String displayName;
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
}

View File

@@ -9,3 +9,6 @@ spring.datasource.driver-class-name=org.postgresql.Driver
#spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
security.jwt.secret=ballistic-test-secret-key-1234567890-ABCDEFGHIJKLNMOPQRST
security.jwt.access-token-minutes=2880