diff --git a/src/main/java/com/slotline/security/AuthenticatedUser.java b/src/main/java/com/slotline/security/AuthenticatedUser.java new file mode 100644 index 0000000..d58e0d7 --- /dev/null +++ b/src/main/java/com/slotline/security/AuthenticatedUser.java @@ -0,0 +1,6 @@ +package com.slotline.security; + +import com.slotline.user.Role; + +public record AuthenticatedUser(Long id, String username, Role role, Long organizationId) { +} diff --git a/src/main/java/com/slotline/security/JwtAuthenticationFilter.java b/src/main/java/com/slotline/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a4eb031 --- /dev/null +++ b/src/main/java/com/slotline/security/JwtAuthenticationFilter.java @@ -0,0 +1,50 @@ +package com.slotline.security; + +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtService jwtService; + + public JwtAuthenticationFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + boolean bearer = header != null && header.startsWith(BEARER_PREFIX); + if (bearer && SecurityContextHolder.getContext().getAuthentication() == null) { + try { + AuthenticatedUser principal = jwtService.parse(header.substring(BEARER_PREFIX.length())); + List authorities = + List.of(new SimpleGrantedAuthority("ROLE_" + principal.role().name())); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(principal, null, authorities); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (JwtException invalidToken) { + SecurityContextHolder.clearContext(); + } + } + chain.doFilter(request, response); + } +} diff --git a/src/main/java/com/slotline/security/JwtService.java b/src/main/java/com/slotline/security/JwtService.java new file mode 100644 index 0000000..a84906f --- /dev/null +++ b/src/main/java/com/slotline/security/JwtService.java @@ -0,0 +1,55 @@ +package com.slotline.security; + +import com.slotline.config.SlotlineProperties; +import com.slotline.user.Role; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + +@Service +public class JwtService { + + private final SecretKey signingKey; + private final Duration expiration; + private final String issuer; + + public JwtService(SlotlineProperties properties) { + this.signingKey = Keys.hmacShaKeyFor(properties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8)); + this.expiration = properties.getJwt().getExpiration(); + this.issuer = properties.getJwt().getIssuer(); + } + + public String issue(Long userId, String username, Role role, Long organizationId) { + Instant now = Instant.now(); + return Jwts.builder() + .issuer(issuer) + .subject(username) + .claim("uid", userId) + .claim("role", role.name()) + .claim("org", organizationId) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(expiration))) + .signWith(signingKey) + .compact(); + } + + public AuthenticatedUser parse(String token) { + Claims claims = Jwts.parser() + .verifyWith(signingKey) + .requireIssuer(issuer) + .build() + .parseSignedClaims(token) + .getPayload(); + Long userId = ((Number) claims.get("uid")).longValue(); + Role role = Role.valueOf(claims.get("role", String.class)); + Long organizationId = ((Number) claims.get("org")).longValue(); + return new AuthenticatedUser(userId, claims.getSubject(), role, organizationId); + } +} diff --git a/src/main/java/com/slotline/security/RestAuthenticationHandlers.java b/src/main/java/com/slotline/security/RestAuthenticationHandlers.java new file mode 100644 index 0000000..7dfdfa7 --- /dev/null +++ b/src/main/java/com/slotline/security/RestAuthenticationHandlers.java @@ -0,0 +1,41 @@ +package com.slotline.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class RestAuthenticationHandlers { + + private final ObjectMapper objectMapper; + + public RestAuthenticationHandlers(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public AuthenticationEntryPoint authenticationEntryPoint() { + return (request, response, exception) -> + write(response, HttpStatus.UNAUTHORIZED, "Authentication required", request.getRequestURI()); + } + + public AccessDeniedHandler accessDeniedHandler() { + return (request, response, exception) -> + write(response, HttpStatus.FORBIDDEN, "Access denied", request.getRequestURI()); + } + + private void write(HttpServletResponse response, HttpStatus status, String detail, String path) throws IOException { + ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail); + problem.setTitle(status.getReasonPhrase()); + problem.setProperty("path", path); + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), problem); + } +}