Angular JS+Spring Boot Web应用并发登录检测及处理方案问询
Got it, let's walk through how to build concurrent login detection with user-controlled actions in your AngularJS + Spring Boot app. This approach gives users control over their sessions while keeping your app secure.
Core Concept
The key is to track each user's active sessions (using a persistent store like Redis for scalability). When a new login request comes in, we check if the user already has an active session. If yes, we prompt the new login user to choose between:
- Forcing the old session to log out and proceeding with the new login
- Canceling the new login and leaving the old session intact
Backend (Spring Boot) Implementation
1. Session Tracking Setup
Use Redis to store active sessions for each user. We'll map user IDs to a list of valid session IDs (we can use JWT's jti claim as the unique session ID for each login).
2. Modified Login Endpoint
Update your login API to check for existing active sessions before completing the login:
@PostMapping("/api/auth/login") public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest loginRequest) { // Validate user credentials (username/password) UserDetails userDetails = userDetailsService.loadUserByUsername(loginRequest.getUsername()); if (!passwordEncoder.matches(loginRequest.getPassword(), userDetails.getPassword())) { return ResponseEntity.badRequest().body("Invalid credentials"); } User user = userRepository.findByUsername(loginRequest.getUsername()); String userSessionKey = "user:sessions:" + user.getId(); // Check if user has active sessions List<String> activeSessions = redisTemplate.opsForList().range(userSessionKey, 0, -1); if (!activeSessions.isEmpty()) { // Return response indicating concurrent login needs user action ConcurrentLoginResponse response = new ConcurrentLoginResponse(); response.setHasActiveSession(true); response.setOldSessionId(activeSessions.get(0)); // Track the most recent old session response.setMessage("Your account is logged in on another device. Choose an action."); response.setUserId(user.getId()); return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); } // No active sessions: proceed with normal login String sessionId = UUID.randomUUID().toString(); String jwtToken = jwtUtil.generateToken(userDetails, sessionId); // Store new session in Redis redisTemplate.opsForList().rightPush(userSessionKey, sessionId); return ResponseEntity.ok(new AuthResponse(jwtToken)); }
3. Action Handling Endpoints
Create two endpoints to handle the user's choice:
Force Logout Old Session
@PostMapping("/api/auth/force-logout-old") public ResponseEntity<?> forceLogoutOldSession(@RequestBody ConcurrentLoginActionRequest request) { String userSessionKey = "user:sessions:" + request.getUserId(); // Remove the old session from Redis redisTemplate.opsForList().remove(userSessionKey, 1, request.getOldSessionId()); // Generate new token for the new login User user = userRepository.findById(request.getUserId()).orElseThrow(); UserDetails userDetails = userDetailsService.loadUserByUsername(user.getUsername()); String newSessionId = UUID.randomUUID().toString(); String newJwtToken = jwtUtil.generateToken(userDetails, newSessionId); // Store new session redisTemplate.opsForList().rightPush(userSessionKey, newSessionId); return ResponseEntity.ok(new AuthResponse(newJwtToken)); }
Cancel New Login
@PostMapping("/api/auth/cancel-new-login") public ResponseEntity<?> cancelNewLogin() { return ResponseEntity.ok("New login canceled. Old session remains active."); }
4. Session Validation Interceptor
Add an interceptor to check if the current session is still valid on every request:
@Component public class SessionValidationInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Autowired private RedisTemplate<String, String> redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } String token = authHeader.substring(7); String userId = jwtUtil.extractUserId(token); String sessionId = jwtUtil.extractJti(token); String userSessionKey = "user:sessions:" + userId; List<String> activeSessions = redisTemplate.opsForList().range(userSessionKey, 0, -1); if (!activeSessions.contains(sessionId)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } return true; } }
Don't forget to register the interceptor in your Spring config to apply it to all API requests.
Frontend (AngularJS) Implementation
1. Login Controller with Concurrent Login Prompt
Modify your login controller to handle the 202 Accepted response and prompt the user with a modal:
angular.module('yourApp') .controller('LoginController', ['$scope', '$http', '$uibModal', '$location', function($scope, $http, $uibModal, $location) { $scope.login = function() { $http.post('/api/auth/login', { username: $scope.username, password: $scope.password }).then(function(response) { if (response.status === 202) { // Show concurrent login choice modal var modalInstance = $uibModal.open({ templateUrl: 'concurrent-login-modal.html', controller: 'ConcurrentLoginModalCtrl', resolve: { sessionData: function() { return response.data; } } }); modalInstance.result.then(function(action) { if (action === 'force') { // Call force logout endpoint $http.post('/api/auth/force-logout-old', { userId: response.data.userId, oldSessionId: response.data.oldSessionId }).then(function(res) { localStorage.setItem('authToken', res.data.token); $location.path('/dashboard'); }); } else if (action === 'cancel') { $scope.errorMsg = 'Login canceled. Your old session is still active.'; } }); } else { // Normal login success localStorage.setItem('authToken', response.data.token); $location.path('/dashboard'); } }).catch(function(error) { $scope.errorMsg = 'Invalid username or password'; }); }; }]) .controller('ConcurrentLoginModalCtrl', ['$scope', '$uibModalInstance', 'sessionData', function($scope, $uibModalInstance, sessionData) { $scope.sessionData = sessionData; $scope.forceLogout = function() { $uibModalInstance.close('force'); }; $scope.cancelLogin = function() { $uibModalInstance.close('cancel'); }; }]);
2. Concurrent Login Modal Template (concurrent-login-modal.html)
<div class="modal-header"> <h3>Concurrent Login Detected</h3> </div> <div class="modal-body"> <p>{{ sessionData.message }}</p> </div> <div class="modal-footer"> <button class="btn btn-danger" ng-click="forceLogout()">Force Logout Old Session</button> <button class="btn btn-default" ng-click="cancelLogin()">Cancel This Login</button> </div>
3. Global HTTP Interceptor for Session Expiry
Add an interceptor to handle 401 Unauthorized responses (triggered when the old session is forced out):
angular.module('yourApp') .factory('authInterceptor', ['$q', '$location', function($q, $location) { return { responseError: function(response) { if (response.status === 401) { localStorage.removeItem('authToken'); $location.path('/login'); alert('Your session has been terminated by a new login. Please log in again.'); } return $q.reject(response); } }; }]) .config(['$httpProvider', function($httpProvider) { $httpProvider.interceptors.push('authInterceptor'); }]);
End-to-End Flow
- User logs in on Device A: Backend stores their session in Redis and returns a JWT.
- User attempts to log in on Device B: Backend detects an active session, returns a
202response. - Device B shows the modal with two options.
- If user chooses "Force Logout": Backend removes Device A's session from Redis, returns a new JWT to Device B. Device A's next request gets a
401and redirects to login. - If user chooses "Cancel": Device B stays on the login page, Device A's session remains active.
- If user chooses "Force Logout": Backend removes Device A's session from Redis, returns a new JWT to Device B. Device A's next request gets a
内容的提问来源于stack exchange,提问作者user1797298

