Skip to content

Commit 1a0e3c6

Browse files
committed
Add JWT authentication
1 parent dba9904 commit 1a0e3c6

30 files changed

Lines changed: 510 additions & 105 deletions

backend/.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
DB_URL=<your-database-url>
22
DB_USERNAME=<your-database-username>
3-
DB_PASSWORD=<your-database-password>
3+
DB_PASSWORD=<your-database-password>
4+
APP_JWT_SECRET=<your-jwt-secret>
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.apexgrid.transformertracker.auth;
2+
3+
import io.jsonwebtoken.ExpiredJwtException;
4+
import io.jsonwebtoken.JwtException;
5+
import jakarta.servlet.FilterChain;
6+
import jakarta.servlet.ServletException;
7+
import jakarta.servlet.http.HttpServletRequest;
8+
import jakarta.servlet.http.HttpServletResponse;
9+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
10+
import org.springframework.security.core.context.SecurityContextHolder;
11+
import org.springframework.security.core.userdetails.UserDetails;
12+
import org.springframework.security.core.userdetails.UserDetailsService;
13+
import org.springframework.security.core.userdetails.UsernameNotFoundException;
14+
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
15+
import org.springframework.stereotype.Component;
16+
import org.springframework.util.StringUtils;
17+
import org.springframework.web.filter.OncePerRequestFilter;
18+
19+
import java.io.IOException;
20+
import java.nio.charset.StandardCharsets;
21+
22+
@Component
23+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
24+
25+
private final JwtService jwtService;
26+
private final UserDetailsService userDetailsService;
27+
28+
public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
29+
this.jwtService = jwtService;
30+
this.userDetailsService = userDetailsService;
31+
}
32+
33+
@Override
34+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
35+
throws ServletException, IOException {
36+
final String authHeader = request.getHeader("Authorization");
37+
38+
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
39+
chain.doFilter(request, response);
40+
return;
41+
}
42+
43+
final String jwt = authHeader.substring(7);
44+
final String username;
45+
try {
46+
username = jwtService.extractUsername(jwt);
47+
} catch (ExpiredJwtException ex) {
48+
respondUnauthorized(response, "Token expired");
49+
return;
50+
} catch (JwtException | IllegalArgumentException ex) {
51+
respondUnauthorized(response, "Invalid token");
52+
return;
53+
}
54+
55+
if (StringUtils.hasText(username) && SecurityContextHolder.getContext().getAuthentication() == null) {
56+
UserDetails userDetails;
57+
try {
58+
userDetails = userDetailsService.loadUserByUsername(username);
59+
} catch (UsernameNotFoundException ex) {
60+
respondUnauthorized(response, "Invalid token");
61+
return;
62+
}
63+
if (jwtService.isTokenValid(jwt, userDetails)) {
64+
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
65+
userDetails, null, userDetails.getAuthorities());
66+
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
67+
SecurityContextHolder.getContext().setAuthentication(authToken);
68+
} else {
69+
respondUnauthorized(response, "Invalid token");
70+
return;
71+
}
72+
}
73+
74+
chain.doFilter(request, response);
75+
}
76+
77+
private void respondUnauthorized(HttpServletResponse response, String message) throws IOException {
78+
if (response.isCommitted()) {
79+
return;
80+
}
81+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
82+
response.setContentType("application/json");
83+
response.getOutputStream().write(("{\"error\":\"" + message + "\"}").getBytes(StandardCharsets.UTF_8));
84+
}
85+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.apexgrid.transformertracker.auth;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
import org.springframework.stereotype.Component;
5+
6+
@Component
7+
@ConfigurationProperties(prefix = "app.jwt")
8+
public class JwtProperties {
9+
/**
10+
* HMAC secret used to sign JWT tokens. Should be at least 32 bytes.
11+
*/
12+
private String secret;
13+
14+
/**
15+
* Token expiry in seconds.
16+
*/
17+
private long expirySeconds = 86400;
18+
19+
public String getSecret() {
20+
return secret;
21+
}
22+
23+
public void setSecret(String secret) {
24+
this.secret = secret;
25+
}
26+
27+
public long getExpirySeconds() {
28+
return expirySeconds;
29+
}
30+
31+
public void setExpirySeconds(long expirySeconds) {
32+
this.expirySeconds = expirySeconds;
33+
}
34+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.apexgrid.transformertracker.auth;
2+
3+
import io.jsonwebtoken.Claims;
4+
import io.jsonwebtoken.Jwts;
5+
import io.jsonwebtoken.SignatureAlgorithm;
6+
import io.jsonwebtoken.io.Decoders;
7+
import io.jsonwebtoken.security.Keys;
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.util.StringUtils;
10+
11+
import javax.crypto.SecretKey;
12+
import java.nio.charset.StandardCharsets;
13+
import java.time.Instant;
14+
import java.util.Date;
15+
import java.util.function.Function;
16+
17+
@Service
18+
public class JwtService {
19+
private final JwtProperties properties;
20+
private final SecretKey signingKey;
21+
22+
public JwtService(JwtProperties properties) {
23+
this.properties = properties;
24+
this.signingKey = buildKey(properties.getSecret());
25+
}
26+
27+
public String generateToken(org.springframework.security.core.userdetails.UserDetails userDetails) {
28+
Instant now = Instant.now();
29+
Instant expiry = now.plusSeconds(properties.getExpirySeconds());
30+
return Jwts.builder()
31+
.setSubject(userDetails.getUsername())
32+
.setIssuedAt(Date.from(now))
33+
.setExpiration(Date.from(expiry))
34+
.signWith(signingKey, SignatureAlgorithm.HS256)
35+
.compact();
36+
}
37+
38+
public String extractUsername(String token) {
39+
return extractClaim(token, Claims::getSubject);
40+
}
41+
42+
public boolean isTokenValid(String token, org.springframework.security.core.userdetails.UserDetails userDetails) {
43+
String username = extractUsername(token);
44+
return StringUtils.hasText(username) && username.equals(userDetails.getUsername()) && !isTokenExpired(token);
45+
}
46+
47+
public long getExpirySeconds() {
48+
return properties.getExpirySeconds();
49+
}
50+
51+
private boolean isTokenExpired(String token) {
52+
Date expiration = extractClaim(token, Claims::getExpiration);
53+
return expiration.before(new Date());
54+
}
55+
56+
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
57+
Claims claims = Jwts.parserBuilder()
58+
.setSigningKey(signingKey)
59+
.build()
60+
.parseClaimsJws(token)
61+
.getBody();
62+
return resolver.apply(claims);
63+
}
64+
65+
private SecretKey buildKey(String secret) {
66+
if (!StringUtils.hasText(secret)) {
67+
throw new IllegalStateException("JWT secret must be configured");
68+
}
69+
byte[] keyBytes;
70+
// Allow both raw strings and base64 encoded secrets. Prefer base64 when possible.
71+
if (isBase64(secret)) {
72+
keyBytes = Decoders.BASE64.decode(secret);
73+
} else {
74+
keyBytes = secret.getBytes(StandardCharsets.UTF_8);
75+
}
76+
if (keyBytes.length < 32) {
77+
throw new IllegalStateException("JWT secret must be at least 32 bytes");
78+
}
79+
return Keys.hmacShaKeyFor(keyBytes);
80+
}
81+
82+
private boolean isBase64(String value) {
83+
try {
84+
Decoders.BASE64.decode(value);
85+
return true;
86+
} catch (IllegalArgumentException ignored) {
87+
return false;
88+
}
89+
}
90+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.apexgrid.transformertracker.auth;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
public record LoginRequest(
6+
@NotBlank(message = "Username is required") String username,
7+
@NotBlank(message = "Password is required") String password
8+
) { }
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.apexgrid.transformertracker.auth;
2+
3+
public record LoginResponse(String token, long expiresIn, String username, String image) { }
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.apexgrid.transformertracker.auth;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import jakarta.servlet.http.HttpServletResponse;
5+
import org.springframework.security.core.AuthenticationException;
6+
import org.springframework.security.web.AuthenticationEntryPoint;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.io.IOException;
10+
import java.nio.charset.StandardCharsets;
11+
12+
@Component
13+
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
14+
@Override
15+
public void commence(HttpServletRequest request,
16+
HttpServletResponse response,
17+
AuthenticationException authException) throws IOException {
18+
if (response.isCommitted()) {
19+
return;
20+
}
21+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
22+
response.setContentType("application/json");
23+
response.getOutputStream().write("{\"error\":\"Unauthorized\"}".getBytes(StandardCharsets.UTF_8));
24+
}
25+
}

backend/src/main/java/com/apexgrid/transformertracker/auth/SecurityConfig.java

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,46 @@
22

33
import org.springframework.context.annotation.Bean;
44
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.http.HttpMethod;
56
import org.springframework.security.authentication.AuthenticationManager;
7+
import org.springframework.security.authentication.AuthenticationProvider;
8+
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
69
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
710
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
811
import org.springframework.security.config.http.SessionCreationPolicy;
12+
import org.springframework.security.core.userdetails.UserDetailsService;
913
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
1014
import org.springframework.security.crypto.password.PasswordEncoder;
1115
import org.springframework.security.web.SecurityFilterChain;
16+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
17+
import org.springframework.security.config.Customizer;
1218

1319
@Configuration
1420
public class SecurityConfig {
1521

22+
private final JwtAuthenticationFilter jwtAuthenticationFilter;
23+
private final RestAuthenticationEntryPoint authenticationEntryPoint;
24+
25+
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
26+
RestAuthenticationEntryPoint authenticationEntryPoint) {
27+
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
28+
this.authenticationEntryPoint = authenticationEntryPoint;
29+
}
30+
1631
@Bean
17-
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
32+
public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationProvider authenticationProvider) throws Exception {
1833
http
1934
.csrf(csrf -> csrf.disable())
20-
.cors(cors -> {})
35+
.cors(Customizer.withDefaults())
2136
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
2237
.authorizeHttpRequests(reg -> reg
23-
.requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll()
38+
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
2439
.requestMatchers("/api/login").permitAll()
25-
.anyRequest().permitAll() // switch to authenticated() after wiring JWT filter
26-
);
40+
.anyRequest().authenticated()
41+
)
42+
.exceptionHandling(conf -> conf.authenticationEntryPoint(authenticationEntryPoint))
43+
.authenticationProvider(authenticationProvider)
44+
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
2745
return http.build();
2846
}
2947

@@ -36,4 +54,13 @@ public PasswordEncoder passwordEncoder() {
3654
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
3755
return configuration.getAuthenticationManager();
3856
}
57+
58+
@Bean
59+
public AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
60+
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
61+
provider.setUserDetailsService(userDetailsService);
62+
provider.setPasswordEncoder(passwordEncoder);
63+
return provider;
64+
}
65+
3966
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.apexgrid.transformertracker.auth;
2+
3+
import com.apexgrid.transformertracker.repo.UserRepo;
4+
import org.springframework.security.core.userdetails.User;
5+
import org.springframework.security.core.userdetails.UserDetails;
6+
import org.springframework.security.core.userdetails.UserDetailsService;
7+
import org.springframework.security.core.userdetails.UsernameNotFoundException;
8+
import org.springframework.stereotype.Service;
9+
10+
@Service
11+
public class UserDetailsServiceImpl implements UserDetailsService {
12+
13+
private final UserRepo userRepo;
14+
15+
public UserDetailsServiceImpl(UserRepo userRepo) {
16+
this.userRepo = userRepo;
17+
}
18+
19+
@Override
20+
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
21+
var user = userRepo.findByUsername(username)
22+
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
23+
return User.withUsername(user.getUsername())
24+
.password(user.getPasswordHash())
25+
.authorities("ROLE_USER")
26+
.build();
27+
}
28+
}

0 commit comments

Comments
 (0)