Implementing Stateless Authentication and Role-Based Access Control with Spring Security

Core Concepts and Architecture Overview

Spring Security operates as a comprehensive security framework within the Spring ecosystem. Compared to alternatives like Apache Shiro, it offers a broader feature set and a more extensive community. While lightweight applications often favor Shiro for its simplicity, enterprise-grade systems typically rely on Spring Security due to its deep integration and robust lifecycle management.

Web application security fundamentally revolves around two pillars:

  • Authentication: Verifying the identity of a requesting entity and establishing exactly who is interacting with the system.
  • Authorization: Determining whether an authenticated identity possesses the necessary privileges to execute a specific operation or access a protected resource.

These mechanisms form the foundation of Spring Security's design.

Project Initialization and Basic Setup

Begin by scaffolding a standard Spring Boot application. Configure the parent POM and include foundational dependencies for web handling and development utilities.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
</parent>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

Create the main application class and a basic REST controller to verify routing functionality:

@SpringBootApplication
public class SecurityApp {
    public static void main(String[] args) {
        SpringApplication.run(SecurityApp.class, args);
    }
}

@RestController
public class DemoEndpoint {
    @GetMapping("/api/status")
    public String getStatus() {
        return "system_online";
    }
}

Integrating the Security Framework

Activate Spring Security by adding its starter dependency. The framework automatically configures a default login interface and enforces authentication on all endpoints.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Upon launching the application, accessing /api/status will trigger an automatic redirect to the framework's default authentication page. Default credentials are generated at startup (username: user, password: printed to console logs).

Understanding the Filter Chain and Core Interfaces

Spring Security operates through a sequential chain of servlet filters. Key components include:

  • UsernamePasswordAuthenticationFilter: Intercepts login form submissions containing credentials.
  • ExceptionTranslationFilter: Catches AuthenticationException and AccessDeniedException, translating them into appropriate HTTP responses.
  • FilterSecurityInterceptor: The terminal filter responsible for evaluating authorization rules against the request.

Central interfaces governing the authentication flow:

  • Authentication: Represents the principal's identity and credentials within the security context.
  • AuthenticationManager: Orchestrates the verification process by delegating to configured providers.
  • UserDetailsService: Contract for retrieving user-specific data, primarily by username.
  • UserDetails: Encapsulates core user metadata, roles, and account status flags.

Designing a Stateless JWT + Redis Authentication Flow

For modern stateless architectures, the authentication workflow is typically structured as follows:

  1. Expose a dedicated login endpoint that bypasses security filters.
  2. Validate credentials via AuthenticationManager. Upon success, generate a JSON Web Token (JWT).
  3. Cache the principal's session data in Redis, using the user identifier as the cache key.
  4. Implement a custom validation filter that intercepts subsequent requests, extracts the JWT, retrieves session data from Redis, and populates SecurityContextHolder.

Infrastructure and Utility Configuration

Include dependencies for Redis, JWT parsing, and JSON serialization:

<!-- Data Caching -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Token Generation -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

Configure Redis serialization to utilize FastJSON for efficient payload handling:

@Configuration
public class RedisConfiguration {
    @Bean
    public RedisTemplate<String, Object> configureTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericFastJsonRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new GenericFastJsonRedisSerializer());
        return template;
    }
}

Implement a standardized response wrapper:

@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResult<T> {
    private Integer statusCode;
    private String message;
    private T payload;

    public static <T> ApiResult<T> success(T data) {
        return new ApiResult<>(200, "operation_successful", data);
    }
    // Constructors, getters, setters omitted for brevity
}

Create a token management utility:

public class JwtHelper {
    private static final String SECRET = "base64_encoded_secret_key_here";
    private static final long EXPIRATION_MS = 3600000L;

    public static String generateToken(String subject) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + EXPIRATION_MS);
        SecretKey key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(SECRET));
        
        return Jwts.builder()
                .setId(UUID.randomUUID().toString().replace("-", ""))
                .setSubject(subject)
                .setIssuedAt(now)
                .setExpiration(expiry)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public static Claims extractClaims(String token) {
        SecretKey key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(SECRET));
        return Jwts.parserBuilder().setSigningKey(key).build()
                .parseClaimsJws(token).getBody();
    }
}

Define a centralized Redis cache manager:

@Component
public class CacheRepository {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public void store(String key, Object value, long duration, TimeUnit unit) {
        redisTemplate.opsForValue().set(key, value, duration, unit);
    }

    public Object retrieve(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public void evict(String key) {
        redisTemplate.delete(key);
    }
}

Database Integration and User Details Service

Establish a user schema to persist credentials:

CREATE TABLE app_user (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(100) NOT NULL,
    account_status CHAR(1) DEFAULT '0',
    email VARCHAR(100)
);

Integrate MyBatis-Plus for data access and define the entity mapping:

@MapperScan("com.example.security.mapper")
@SpringBootApplication
public class SecurityApp { ... }

@TableName("app_user")
@Data
public class UserEntity implements Serializable {
    @TableId
    private Long id;
    private String username;
    private String password;
    private String accountStatus;
    private String email;
}

Implement UserDetailsService to load user data from the database:

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired private UserMapper userRepo;

    @Override
    public UserDetails loadUserByUsername(String username) {
        UserEntity user = userRepo.selectOne(
            Wrappers.<UserEntity>lambdaQuery().eq(UserEntity::getUsername, username)
        );
        if (user == null) {
            throw new BadCredentialsException("Invalid credentials");
        }
        // Fetch permissions here (covered later)
        List<String> permissions = List.of();
        return new SessionPrincipal(user, permissions);
    }
}

Wrap the database entity into a Spring Security UserDetails implementation:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionPrincipal implements UserDetails {
    private UserEntity account;
    private List<String> permissions;
    
    @JSONField(serialize = false)
    private Collection<? extends GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (authorities != null) return authorities;
        authorities = permissions.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        return authorities;
    }

    @Override
    public String getPassword() { return account.getPassword(); }
    @Override
    public String getUsername() { return account.getUsername(); }
    @Override
    public boolean isAccountNonExpired() { return true; }
    @Override
    public boolean isAccountNonLocked() { return true; }
    @Override
    public boolean isCredentialsNonExpired() { return true; }
    @Override
    public boolean isEnabled() { return true; }
}

Password Encoding and Security Configuration

Never store plaintext passwords. Configure a BCryptPasswordEncoder bean and disable session-based context storage to enforce stateless operasions:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, 
                                                   JwtValidationFilter jwtFilter) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/login").anonymous()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public AuthenticationManager authManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

Authentication and Token Validation Logic

Expose a dedicated login endpoint that delegates credential verification to AuthenticationManager:

@RestController
@RequestMapping("/auth")
public class AuthenticationController {
    @Autowired private LoginService authService;
    
    @PostMapping("/login")
    public ApiResult<Map<String, String>> authenticate(@RequestBody LoginRequest dto) {
        return authService.performLogin(dto.getUsername(), dto.getPassword());
    }
    
    @PostMapping("/logout")
    public ApiResult<String> signOut() {
        return authService.performLogout();
    }
}

Implement the login service logic:

@Service
public class LoginServiceImpl implements LoginService {
    @Autowired private AuthenticationManager manager;
    @Autowired private CacheRepository cache;
    
    public ApiResult<Map<String, String>> performLogin(String username, String password) {
        Authentication token = new UsernamePasswordAuthenticationToken(username, password);
        Authentication verified = manager.authenticate(token);
        
        SessionPrincipal principal = (SessionPrincipal) verified.getPrincipal();
        String sessionId = principal.getAccount().getId().toString();
        String jwt = JwtHelper.generateToken(sessionId);
        
        cache.store("auth_session:" + sessionId, principal, 1, TimeUnit.HOURS);
        
        Map<String, String> response = new HashMap<>();
        response.put("accessToken", jwt);
        return ApiResult.success(response);
    }
    
    public ApiResult<String> performLogout() {
        Authentication current = SecurityContextHolder.getContext().getAuthentication();
        SessionPrincipal principal = (SessionPrincipal) current.getPrincipal();
        cache.evict("auth_session:" + principal.getAccount().getId());
        return ApiResult.success("logged_out");
    }
}

Develop a custom filter to validate incoming tokens and reconstruct the security context:

@Component
public class JwtValidationFilter extends OncePerRequestFilter {
    @Autowired private CacheRepository cache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        String headerToken = request.getHeader("X-Auth-Token");
        if (headerToken == null || headerToken.isBlank()) {
            chain.doFilter(request, response);
            return;
        }
        
        try {
            Claims claims = JwtHelper.extractClaims(headerToken);
            String principalId = claims.getSubject();
            SessionPrincipal sessionData = (SessionPrincipal) cache.retrieve("auth_session:" + principalId);
            
            if (sessionData != null) {
                UsernamePasswordAuthenticationToken auth = 
                    new UsernamePasswordAuthenticationToken(sessionData, null, sessionData.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        } catch (Exception ex) {
            throw new JwtException("Invalid or expired token");
        }
        chain.doFilter(request, response);
    }
}

Authorization and Role-Based Access Control

Authorization in Spring Security is typically driven by the RBAC model. Users are assigned roles, and roles are mapped to specific permissions. Enable method-level security to leverage annotasions:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class GlobalSecurityConfig { }

Apply permission checks directly to controller endpoints:

@RestController
public class AdminController {
    @GetMapping("/system/config")
    @PreAuthorize("hasAuthority('system:config:read')")
    public String getConfig() { return "restricted_config_data"; }
}

Retrieve permissions dynamically using a custom SQL query that joins user, role, role-menu, and menu tables:

<select id="fetchPermissions" resultType="java.lang.String">
    SELECT DISTINCT m.permission_code
    FROM user_role ur
    JOIN role r ON ur.role_id = r.id
    JOIN role_menu rm ON r.id = rm.role_id
    JOIN menu m ON rm.menu_id = m.id
    WHERE ur.user_id = #{userId}
      AND r.is_active = 1
      AND m.is_active = 1
</select>

Inject the mapper into CustomUserDetailsService to populate the permissions list during login.

Custom Exception Handling

Standardize error responses by implementing AuthenticationEntryPoint and AccessDeniedHandler:

@Component
public class SecurityExceptionHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
    
    private void writeResponse(HttpServletResponse res, int status, String message) throws IOException {
        res.setContentType("application/json;charset=UTF-8");
        res.setStatus(status);
        res.getWriter().write(JsonUtils.toJson(new ApiResult<>(status, message, null)));
    }
    
    @Override
    public void commence(HttpServletRequest req, HttpServletResponse res, AuthenticationException authEx) throws IOException {
        writeResponse(res, HttpStatus.UNAUTHORIZED.value(), "authentication_required");
    }
    
    @Override
    public void handle(HttpServletRequest req, HttpServletResponse res, AccessDeniedException accessEx) throws IOException {
        writeResponse(res, HttpStatus.FORBIDDEN.value(), "insufficient_privileges");
    }
}

Register the handler in the security configuration:

.exceptionHandling(ex -> ex
    .authenticationEntryPoint(handler)
    .accessDeniedHandler(handler)
)

Cross-Origin Resource Sharing Configuration

Configure CORS at both the Spring MVC and Security filter levels to allow frontend requests:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowCredentials(true)
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .maxAge(3600);
    }
}
http.cors(Customizer.withDefaults());

Note: CSRF protection is typically disabled in stateless, token-based architectures since authentication tokens are manually attached to request headers rather than automatically sent via cookies.

Tags: spring-security jwt-authentication spring-boot rbac-authorization redis-session-management

Posted on Sat, 11 Jul 2026 16:02:50 +0000 by PHPBewildered