Simplified Security: Implementing Authentication and Authorization with Apache Shiro in Java


Article

In Java application development, implementing robust security—authentication, authorization, session management, and cryptography—can be complex and time-consuming. Apache Shiro is a powerful, easy-to-use Java security framework that simplifies these concerns while providing comprehensive security capabilities. Unlike the more complex Spring Security, Shiro offers a clean, intuitive API that makes security accessible to Java developers of all experience levels.

What is Apache Shiro?

Apache Shiro is a comprehensive security framework that provides:

  • Authentication: Verify user identity
  • Authorization: Control access to resources
  • Session Management: User session handling, even in non-web applications
  • Cryptography: Simplified encryption and hashing
  • Web Integration: Security for web applications
  • Caching: Performance optimization for security operations

Why Choose Apache Shiro?

  1. Simplicity: Intuitive API and straightforward configuration
  2. Flexibility: Works with any application architecture
  3. Comprehensive: Handles all aspects of security in one framework
  4. Web Agnostic: Can be used in web, desktop, and mobile applications
  5. Lightweight: Minimal dependencies and overhead

Core Shiro Concepts

  • Subject: The current user (human, service, or daemon)
  • SecurityManager: The heart of Shiro, coordinating security operations
  • Realm: Bridge between Shiro and your security data (database, LDAP, etc.)
  • Permission: Atomic privilege to perform an action
  • Role: Named collection of permissions
  • AuthenticationToken: Credentials presented for verification

Setting Up Apache Shiro

1. Add Dependencies:

<!-- pom.xml -->
<dependencies>
<!-- Shiro Core -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.13.0</version>
</dependency>
<!-- Shiro Web -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<version>1.13.0</version>
</dependency>
<!-- Shiro Spring -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.13.0</version>
</dependency>
<!-- Optional: Shiro caching -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>1.13.0</version>
</dependency>
</dependencies>

2. Basic Shiro Configuration:

# shiro.ini

[main]

# Configure Realm myRealm = com.example.shiro.CustomJdbcRealm myRealm.permissionsLookupEnabled = true # Configure SecurityManager securityManager.realms = $myRealm # Configure Cache cacheManager = org.apache.shiro.cache.MemoryConstrainedCacheManager securityManager.cacheManager = $cacheManager # Session Management sessionManager = org.apache.shiro.web.session.mgt.DefaultWebSessionManager securityManager.sessionManager = $sessionManager

[urls]

/login = anon /logout = logout /admin/** = authc, roles[admin] /user/** = authc, perms["user:read"] /api/** = authc /** = authc

Spring Boot Integration

1. Shiro Configuration Class:

@Configuration
public class ShiroConfig {
@Bean
public Realm realm() {
return new CustomJdbcRealm();
}
@Bean
public DefaultWebSecurityManager securityManager(Realm realm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(realm);
// Configure cache
securityManager.setCacheManager(new MemoryConstrainedCacheManager());
// Configure session manager
securityManager.setSessionManager(sessionManager());
return securityManager;
}
@Bean
public SessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setGlobalSessionTimeout(1800000); // 30 minutes
sessionManager.setSessionValidationSchedulerEnabled(true);
return sessionManager;
}
@Bean
public ShiroFilterFactoryBean shiroFilter(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
filterFactoryBean.setSecurityManager(securityManager);
// Configure login and unauthorized URLs
filterFactoryBean.setLoginUrl("/login");
filterFactoryBean.setUnauthorizedUrl("/unauthorized");
filterFactoryBean.setSuccessUrl("/dashboard");
// Configure URL filters
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
// Public endpoints
filterChainDefinitionMap.put("/css/**", "anon");
filterChainDefinitionMap.put("/js/**", "anon");
filterChainDefinitionMap.put("/webjars/**", "anon");
filterChainDefinitionMap.put("/login", "anon");
filterChainDefinitionMap.put("/register", "anon");
// Logout
filterChainDefinitionMap.put("/logout", "logout");
// Role-based access
filterChainDefinitionMap.put("/admin/**", "authc, roles[admin]");
filterChainDefinitionMap.put("/manager/**", "authc, roles[manager]");
// Permission-based access
filterChainDefinitionMap.put("/user/create", "authc, perms[user:create]");
filterChainDefinitionMap.put("/user/delete", "authc, perms[user:delete]");
filterChainDefinitionMap.put("/user/**", "authc, perms[user:read]");
// Authenticated access
filterChainDefinitionMap.put("/**", "authc");
filterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return filterFactoryBean;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(
DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}

Custom Realm Implementation

1. Database-Backed Realm:

@Component
public class CustomJdbcRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
@Autowired
private PasswordService passwordService;
public CustomJdbcRealm() {
// Configure credentials matcher
setCredentialsMatcher(new BCryptCredentialsMatcher());
// Enable caching
setCachingEnabled(true);
setAuthenticationCachingEnabled(true);
setAuthorizationCachingEnabled(true);
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) 
throws AuthenticationException {
UsernamePasswordToken upToken = (UsernamePasswordToken) token;
String username = upToken.getUsername();
// Retrieve user from database
User user = userService.findByUsername(username)
.orElseThrow(() -> new UnknownAccountException("User not found: " + username));
// Check if account is locked
if (!user.isActive()) {
throw new LockedAccountException("Account is locked: " + username);
}
// Return authentication info
return new SimpleAuthenticationInfo(
new ShiroUserPrincipal(user),  // principal
user.getPassword(),            // hashed password
getName()                      // realm name
);
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
ShiroUserPrincipal principal = (ShiroUserPrincipal) principals.getPrimaryPrincipal();
User user = principal.getUser();
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
// Add roles
authorizationInfo.setRoles(userService.getUserRoles(user.getId()));
// Add permissions
authorizationInfo.setStringPermissions(userService.getUserPermissions(user.getId()));
return authorizationInfo;
}
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof UsernamePasswordToken;
}
}

2. User Principal Wrapper:

public class ShiroUserPrincipal implements Serializable {
private final User user;
private final Set<String> roles;
private final Set<String> permissions;
public ShiroUserPrincipal(User user) {
this.user = user;
this.roles = Collections.unmodifiableSet(new HashSet<>());
this.permissions = Collections.unmodifiableSet(new HashSet<>());
}
public ShiroUserPrincipal(User user, Set<String> roles, Set<String> permissions) {
this.user = user;
this.roles = Collections.unmodifiableSet(roles);
this.permissions = Collections.unmodifiableSet(permissions);
}
// Getters
public User getUser() { return user; }
public Set<String> getRoles() { return roles; }
public Set<String> getPermissions() { return permissions; }
@Override
public String toString() {
return user.getUsername();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ShiroUserPrincipal that = (ShiroUserPrincipal) o;
return Objects.equals(user.getId(), that.user.getId());
}
@Override
public int hashCode() {
return Objects.hash(user.getId());
}
}

Service Layer Implementation

1. User Service:

@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private RoleRepository roleRepository;
@Autowired
private PasswordService passwordService;
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username);
}
public Set<String> getUserRoles(Long userId) {
return roleRepository.findRoleNamesByUserId(userId);
}
public Set<String> getUserPermissions(Long userId) {
return roleRepository.findPermissionNamesByUserId(userId);
}
public User createUser(User user, Set<String> roleNames) {
// Hash password
String hashedPassword = passwordService.hashPassword(user.getPassword());
user.setPassword(hashedPassword);
user.setActive(true);
user.setCreatedAt(LocalDateTime.now());
// Save user
User savedUser = userRepository.save(user);
// Assign roles
assignRoles(savedUser.getId(), roleNames);
return savedUser;
}
public void changePassword(Long userId, String newPassword) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
String hashedPassword = passwordService.hashPassword(newPassword);
user.setPassword(hashedPassword);
userRepository.save(user);
}
private void assignRoles(Long userId, Set<String> roleNames) {
// Implementation for role assignment
}
}

2. Password Service:

@Service
public class PasswordService {
private final DefaultHashService hashService;
private final HashRequest.Builder hashRequestBuilder;
public PasswordService() {
this.hashService = new DefaultHashService();
hashService.setHashAlgorithmName("SHA-256");
hashService.setGeneratePublicSalt(true);
hashService.setHashIterations(500000);
this.hashRequestBuilder = new HashRequest.Builder();
}
public String hashPassword(String plainPassword) {
HashRequest request = hashRequestBuilder
.setSource(ByteSource.Util.bytes(plainPassword))
.build();
Hash hash = hashService.computeHash(request);
return hash.toHex();
}
public boolean verifyPassword(String plainPassword, String hashedPassword) {
HashRequest request = hashRequestBuilder
.setSource(ByteSource.Util.bytes(plainPassword))
.build();
Hash computedHash = hashService.computeHash(request);
return computedHash.toHex().equals(hashedPassword);
}
}

Controller Implementation

1. Authentication Controller:

@Controller
public class AuthController {
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
@GetMapping("/login")
public String loginForm(@RequestParam(value = "error", required = false) String error,
@RequestParam(value = "logout", required = false) String logout,
Model model) {
if (error != null) {
model.addAttribute("error", "Invalid username or password");
}
if (logout != null) {
model.addAttribute("message", "You have been logged out successfully");
}
return "login";
}
@PostMapping("/login")
public String login(@RequestParam String username,
@RequestParam String password,
@RequestParam(value = "rememberMe", defaultValue = "false") boolean rememberMe,
Model model) {
try {
Subject currentUser = SecurityUtils.getSubject();
if (!currentUser.isAuthenticated()) {
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
token.setRememberMe(rememberMe);
currentUser.login(token);
logger.info("User {} logged in successfully", username);
return "redirect:/dashboard";
}
} catch (UnknownAccountException e) {
model.addAttribute("error", "User not found");
} catch (IncorrectCredentialsException e) {
model.addAttribute("error", "Invalid password");
} catch (LockedAccountException e) {
model.addAttribute("error", "Account is locked");
} catch (AuthenticationException e) {
model.addAttribute("error", "Authentication failed: " + e.getMessage());
}
return "login";
}
@GetMapping("/logout")
public String logout() {
Subject currentUser = SecurityUtils.getSubject();
if (currentUser != null) {
currentUser.logout();
}
return "redirect:/login?logout=true";
}
@GetMapping("/unauthorized")
public String unauthorized() {
return "unauthorized";
}
}

2. Secure REST API Controller:

@RestController
@RequestMapping("/api")
public class UserApiController {
@Autowired
private UserService userService;
@GetMapping("/user/profile")
public ResponseEntity<UserProfile> getUserProfile() {
Subject currentUser = SecurityUtils.getSubject();
ShiroUserPrincipal principal = (ShiroUserPrincipal) currentUser.getPrincipal();
UserProfile profile = UserProfile.builder()
.username(principal.getUser().getUsername())
.email(principal.getUser().getEmail())
.roles(principal.getRoles())
.permissions(principal.getPermissions())
.build();
return ResponseEntity.ok(profile);
}
@RequiresAuthentication
@GetMapping("/users")
public ResponseEntity<List<User>> listUsers() {
List<User> users = userService.findAllUsers();
return ResponseEntity.ok(users);
}
@RequiresRoles("admin")
@PostMapping("/users")
public ResponseEntity<User> createUser(@RequestBody CreateUserRequest request) {
User user = userService.createUser(request.toUser(), request.getRoles());
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
@RequiresPermissions("user:delete")
@DeleteMapping("/users/{userId}")
public ResponseEntity<Void> deleteUser(@PathVariable Long userId) {
userService.deleteUser(userId);
return ResponseEntity.noContent().build();
}
@RequiresUser
@PutMapping("/user/password")
public ResponseEntity<Void> changePassword(@RequestBody ChangePasswordRequest request) {
Subject currentUser = SecurityUtils.getSubject();
ShiroUserPrincipal principal = (ShiroUserPrincipal) currentUser.getPrincipal();
userService.changePassword(principal.getUser().getId(), request.getNewPassword());
return ResponseEntity.ok().build();
}
}

3. Web MVC Controller with Annotations:

@Controller
@RequestMapping("/admin")
public class AdminController {
@RequiresAuthentication
@GetMapping("/dashboard")
public String adminDashboard(Model model) {
Subject currentUser = SecurityUtils.getSubject();
model.addAttribute("user", currentUser.getPrincipal());
return "admin/dashboard";
}
@RequiresRoles("admin")
@GetMapping("/users")
public String userManagement(Model model) {
model.addAttribute("users", userService.findAllUsers());
return "admin/users";
}
@RequiresPermissions("user:create")
@GetMapping("/users/create")
public String createUserForm(Model model) {
model.addAttribute("user", new User());
return "admin/create-user";
}
@RequiresPermissions("user:create")
@PostMapping("/users")
public String createUser(@ModelAttribute User user, 
@RequestParam Set<String> roles) {
userService.createUser(user, roles);
return "redirect:/admin/users";
}
}

Advanced Shiro Features

1. Custom Permission Implementation:

public class DomainObjectPermission extends WildcardPermission {
private final String domain;
private final Long targetId;
public DomainObjectPermission(String permissionString) {
super(permissionString);
String[] parts = permissionString.split(":");
if (parts.length >= 3) {
this.domain = parts[0];
this.targetId = parts.length > 3 ? Long.parseLong(parts[2]) : null;
} else {
this.domain = null;
this.targetId = null;
}
}
public DomainObjectPermission(String domain, String actions, Long targetId) {
super(domain + ":" + actions + (targetId != null ? ":" + targetId : ""));
this.domain = domain;
this.targetId = targetId;
}
// Getters
public String getDomain() { return domain; }
public Long getTargetId() { return targetId; }
}
// Usage in realm
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
// Add domain-specific permissions
authorizationInfo.addObjectPermission(
new DomainObjectPermission("project", "read,write", 123L)
);
return authorizationInfo;
}

2. Session Management:

@Service
public class SessionService {
public List<Session> getActiveSessions() {
DefaultSecurityManager securityManager = 
(DefaultSecurityManager) SecurityUtils.getSecurityManager();
SessionManager sessionManager = securityManager.getSessionManager();
Collection<Session> activeSessions = 
((DefaultSessionManager) sessionManager).getSessionDAO().getActiveSessions();
return new ArrayList<>(activeSessions);
}
public void expireSession(String sessionId) {
DefaultSecurityManager securityManager = 
(DefaultSecurityManager) SecurityUtils.getSecurityManager();
SessionManager sessionManager = securityManager.getSessionManager();
Session session = sessionManager.getSession(new DefaultSessionKey(sessionId));
if (session != null) {
session.stop();
}
}
public Session getCurrentSession() {
return SecurityUtils.getSubject().getSession();
}
}

3. Remember Me Service:

@Component
public class CustomRememberMeManager extends CookieRememberMeManager {
@Autowired
private UserService userService;
@Override
protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) {
byte[] bytes = super.getRememberedSerializedIdentity(subjectContext);
if (bytes != null) {
// Additional validation for remembered users
validateRememberedUser(bytes);
}
return bytes;
}
private void validateRememberedUser(byte[] serialized) {
try {
String username = new String(serialized, StandardCharsets.UTF_8);
User user = userService.findByUsername(username)
.orElseThrow(() -> new AuthenticationException("Remembered user not found"));
if (!user.isActive()) {
throw new AuthenticationException("Remembered account is disabled");
}
} catch (Exception e) {
forgetIdentity(SecurityUtils.getSubject());
throw new AuthenticationException("Invalid remembered user", e);
}
}
}

Testing Shiro Security

1. Unit Test Configuration:

@ExtendWith(MockitoExtension.class)
public class ShiroSecurityTest {
@Mock
private UserService userService;
private CustomJdbcRealm realm;
@BeforeEach
void setUp() {
realm = new CustomJdbcRealm();
realm.setUserService(userService);
// Set up minimal Shiro environment for testing
DefaultSecurityManager securityManager = new DefaultSecurityManager();
securityManager.setRealm(realm);
SecurityUtils.setSecurityManager(securityManager);
}
@Test
void testSuccessfulAuthentication() {
// Given
User user = new User("testuser", "hashedPassword");
when(userService.findByUsername("testuser")).thenReturn(Optional.of(user));
// When
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken("testuser", "password");
// Then
assertDoesNotThrow(() -> subject.login(token));
assertTrue(subject.isAuthenticated());
}
@Test
void testAuthorization() {
// Given
User user = new User("testuser", "hashedPassword");
when(userService.findByUsername("testuser")).thenReturn(Optional.of(user));
when(userService.getUserRoles(1L)).thenReturn(Set.of("admin", "user"));
when(userService.getUserPermissions(1L)).thenReturn(Set.of("user:read", "user:write"));
// When
Subject subject = SecurityUtils.getSubject();
subject.login(new UsernamePasswordToken("testuser", "password"));
// Then
assertTrue(subject.hasRole("admin"));
assertTrue(subject.isPermitted("user:read"));
assertFalse(subject.isPermitted("user:delete"));
}
}

Best Practices for Production

  1. Secure Session Management: Use secure cookies and session timeouts
  2. Password Hashing: Use strong hashing algorithms with sufficient iterations
  3. Role and Permission Design: Design a clear role and permission hierarchy
  4. Caching Strategy: Implement appropriate caching for performance
  5. Security Monitoring: Log authentication and authorization events
@Aspect
@Component
public class SecurityAuditAspect {
private static final Logger logger = LoggerFactory.getLogger(SecurityAuditAspect.class);
@AfterReturning("execution(* org.apache.shiro.realm..*.*(..))")
public void auditRealmOperations(JoinPoint joinPoint) {
logger.info("Shiro realm operation: {}", joinPoint.getSignature().getName());
}
@AfterThrowing(pointcut = "execution(* org.apache.shiro.realm..*.*(..))", 
throwing = "ex")
public void auditAuthenticationFailures(JoinPoint joinPoint, Exception ex) {
logger.warn("Authentication failure in {}: {}", 
joinPoint.getSignature().getName(), ex.getMessage());
}
}

Conclusion

Apache Shiro provides a clean, intuitive approach to Java application security that balances power with simplicity. Its comprehensive feature set—covering authentication, authorization, session management, and cryptography—makes it suitable for applications of all sizes, from simple web apps to complex enterprise systems.

The framework's flexibility allows it to integrate seamlessly with various data sources and application architectures, while its annotation-based security and URL filtering provide multiple layers of protection. For Java teams looking for a security solution that's both powerful and approachable, Apache Shiro offers an excellent balance of capability and developer productivity.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper