Please provide the code changes or file diffs you would like me to summarize.
This commit is contained in:
@@ -1,73 +1,71 @@
|
||||
package com.sshmanager.config;
|
||||
|
||||
import com.sshmanager.security.JwtAuthenticationFilter;
|
||||
import com.sshmanager.security.PasswordExpirationFilter;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
@Autowired(required = false)
|
||||
private SecurityExceptionHandler securityExceptionHandler;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
|
||||
PasswordExpirationFilter passwordExpirationFilter) {
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
this.passwordExpirationFilter = passwordExpirationFilter;
|
||||
}
|
||||
|
||||
private final PasswordExpirationFilter passwordExpirationFilter;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors().and()
|
||||
.csrf().disable()
|
||||
.sessionManagement()
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
.and()
|
||||
.authorizeRequests()
|
||||
.antMatchers("/api/auth/**").permitAll()
|
||||
.antMatchers("/ws/**").authenticated()
|
||||
.antMatchers("/api/**").authenticated()
|
||||
.anyRequest().permitAll()
|
||||
.and()
|
||||
.exceptionHandling(e -> {
|
||||
if (securityExceptionHandler != null) {
|
||||
e.authenticationEntryPoint(securityExceptionHandler);
|
||||
}
|
||||
})
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(passwordExpirationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
package com.sshmanager.config;
|
||||
|
||||
import com.sshmanager.security.JwtAuthenticationFilter;
|
||||
import com.sshmanager.security.PasswordExpirationFilter;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
@Autowired(required = false)
|
||||
private SecurityExceptionHandler securityExceptionHandler;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
|
||||
PasswordExpirationFilter passwordExpirationFilter) {
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
this.passwordExpirationFilter = passwordExpirationFilter;
|
||||
}
|
||||
|
||||
private final PasswordExpirationFilter passwordExpirationFilter;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors().and()
|
||||
.csrf().disable()
|
||||
.sessionManagement()
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
.and()
|
||||
.authorizeRequests()
|
||||
.antMatchers("/api/auth/**").permitAll()
|
||||
.antMatchers("/ws/**").authenticated()
|
||||
.antMatchers("/api/**").authenticated()
|
||||
.anyRequest().permitAll()
|
||||
.and()
|
||||
.exceptionHandling(e -> {
|
||||
if (securityExceptionHandler != null) {
|
||||
e.authenticationEntryPoint(securityExceptionHandler);
|
||||
}
|
||||
})
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(passwordExpirationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
// Docker/remote deployments may be accessed via IP/hostname.
|
||||
@@ -76,9 +74,9 @@ public class SecurityConfig {
|
||||
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(Arrays.asList("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.sshmanager.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
@@ -15,13 +16,13 @@ import java.io.IOException;
|
||||
public class SpaForwardConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
public void addResourceHandlers(@NonNull ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
protected Resource getResource(String path, Resource location) throws IOException {
|
||||
protected Resource getResource(@NonNull String path, @NonNull Resource location) throws IOException {
|
||||
Resource resource = location.createRelative(path);
|
||||
if (resource.exists() && resource.isReadable()) {
|
||||
return resource;
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.springframework.web.socket.WebSocketHandler;
|
||||
import org.springframework.web.socket.server.HandshakeInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@@ -21,8 +23,8 @@ public class TerminalHandshakeInterceptor implements HandshakeInterceptor {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
|
||||
public boolean beforeHandshake(@NonNull ServerHttpRequest request, @NonNull ServerHttpResponse response,
|
||||
@NonNull WebSocketHandler wsHandler, @NonNull Map<String, Object> attributes) throws Exception {
|
||||
if (!(request instanceof ServletServerHttpRequest)) {
|
||||
return false;
|
||||
}
|
||||
@@ -50,7 +52,7 @@ public class TerminalHandshakeInterceptor implements HandshakeInterceptor {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
|
||||
WebSocketHandler wsHandler, Exception exception) {
|
||||
public void afterHandshake(@NonNull ServerHttpRequest request, @NonNull ServerHttpResponse response,
|
||||
@NonNull WebSocketHandler wsHandler, @Nullable Exception exception) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package com.sshmanager.config;
|
||||
|
||||
package com.sshmanager.config;
|
||||
|
||||
import com.sshmanager.controller.TerminalWebSocketHandler;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
import org.springframework.lang.NonNull;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
public class WebSocketConfig implements WebSocketConfigurer {
|
||||
|
||||
private final TerminalWebSocketHandler terminalWebSocketHandler;
|
||||
@@ -18,9 +19,10 @@ public class WebSocketConfig implements WebSocketConfigurer {
|
||||
this.terminalWebSocketHandler = terminalWebSocketHandler;
|
||||
this.terminalHandshakeInterceptor = terminalHandshakeInterceptor;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
@SuppressWarnings("null")
|
||||
public void registerWebSocketHandlers(@NonNull WebSocketHandlerRegistry registry) {
|
||||
registry.addHandler(terminalWebSocketHandler, "/ws/terminal")
|
||||
.addInterceptors(terminalHandshakeInterceptor)
|
||||
// Docker/remote deployments often use non-localhost origins.
|
||||
|
||||
@@ -14,7 +14,6 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
@@ -33,7 +32,6 @@ import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sftp")
|
||||
@@ -282,7 +280,7 @@ public class SftpController {
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
|
||||
.body(stream);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).build();
|
||||
@@ -331,12 +329,11 @@ public class SftpController {
|
||||
file.transferTo(tempFile);
|
||||
final java.io.File savedFile = tempFile;
|
||||
|
||||
UploadTaskStatus status = new UploadTaskStatus(taskId, userId, connectionId,
|
||||
path, filename, file.getSize());
|
||||
UploadTaskStatus status = new UploadTaskStatus(taskId, userId, filename, file.getSize());
|
||||
status.setController(this);
|
||||
uploadTasks.put(taskKey, status);
|
||||
|
||||
Future<?> future = transferTaskExecutor.submit(() -> {
|
||||
transferTaskExecutor.submit(() -> {
|
||||
status.setStatus("running");
|
||||
try {
|
||||
withSessionLock(key, () -> {
|
||||
@@ -387,8 +384,6 @@ public class SftpController {
|
||||
}
|
||||
}
|
||||
});
|
||||
status.setFuture(future);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("taskId", taskId);
|
||||
result.put("message", "Upload started");
|
||||
@@ -654,6 +649,7 @@ public class SftpController {
|
||||
}
|
||||
|
||||
@GetMapping("/transfer-remote/tasks/{taskId}/progress")
|
||||
@SuppressWarnings("null")
|
||||
public SseEmitter streamTransferProgress(
|
||||
@PathVariable String taskId,
|
||||
Authentication authentication) {
|
||||
@@ -692,6 +688,7 @@ public class SftpController {
|
||||
}
|
||||
|
||||
@GetMapping("/upload/tasks/{taskId}/progress")
|
||||
@SuppressWarnings("null")
|
||||
public SseEmitter streamUploadProgress(
|
||||
@PathVariable String taskId,
|
||||
Authentication authentication) {
|
||||
@@ -739,6 +736,7 @@ public class SftpController {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("null")
|
||||
private void broadcastProgress(String taskKey, Map<String, Object> data) {
|
||||
CopyOnWriteArrayList<SseEmitter> emitters = taskEmitters.get(taskKey);
|
||||
if (emitters == null || emitters.isEmpty()) {
|
||||
@@ -976,10 +974,7 @@ public class SftpController {
|
||||
public static class UploadTaskStatus {
|
||||
private final String taskId;
|
||||
private final Long userId;
|
||||
private final Long connectionId;
|
||||
private final String path;
|
||||
private final String filename;
|
||||
private final long fileSize;
|
||||
private final long createdAt;
|
||||
private volatile String status;
|
||||
private volatile String error;
|
||||
@@ -987,17 +982,12 @@ public class SftpController {
|
||||
private volatile long finishedAt;
|
||||
private final AtomicLong totalBytes;
|
||||
private final AtomicLong transferredBytes;
|
||||
private volatile Future<?> future;
|
||||
private volatile SftpController controller;
|
||||
|
||||
public UploadTaskStatus(String taskId, Long userId, Long connectionId,
|
||||
String path, String filename, long fileSize) {
|
||||
public UploadTaskStatus(String taskId, Long userId, String filename, long fileSize) {
|
||||
this.taskId = taskId;
|
||||
this.userId = userId;
|
||||
this.connectionId = connectionId;
|
||||
this.path = path;
|
||||
this.filename = filename;
|
||||
this.fileSize = fileSize;
|
||||
this.createdAt = System.currentTimeMillis();
|
||||
this.status = "queued";
|
||||
this.totalBytes = new AtomicLong(fileSize);
|
||||
@@ -1012,9 +1002,7 @@ public class SftpController {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
public void setFuture(Future<?> future) {
|
||||
this.future = future;
|
||||
}
|
||||
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
|
||||
@@ -1,107 +1,109 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.ConnectionRepository;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.SshService;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Component
|
||||
public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
|
||||
private final ConnectionRepository connectionRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ConnectionService connectionService;
|
||||
private final SshService sshService;
|
||||
private final ExecutorService executor;
|
||||
|
||||
private final AtomicInteger sessionCount = new AtomicInteger(0);
|
||||
private final Map<String, SshService.SshSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> lastActivity = new ConcurrentHashMap<>();
|
||||
|
||||
public TerminalWebSocketHandler(ConnectionRepository connectionRepository,
|
||||
UserRepository userRepository,
|
||||
ConnectionService connectionService,
|
||||
SshService sshService,
|
||||
@Qualifier("terminalWebSocketExecutor") ExecutorService executor) {
|
||||
this.connectionRepository = connectionRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.connectionService = connectionService;
|
||||
this.sshService = sshService;
|
||||
this.executor = executor;
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.ConnectionRepository;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.SshService;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
import org.springframework.lang.NonNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Component
|
||||
public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
|
||||
private final ConnectionRepository connectionRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ConnectionService connectionService;
|
||||
private final SshService sshService;
|
||||
private final ExecutorService executor;
|
||||
|
||||
private final AtomicInteger sessionCount = new AtomicInteger(0);
|
||||
private final Map<String, SshService.SshSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> lastActivity = new ConcurrentHashMap<>();
|
||||
|
||||
public TerminalWebSocketHandler(ConnectionRepository connectionRepository,
|
||||
UserRepository userRepository,
|
||||
ConnectionService connectionService,
|
||||
SshService sshService,
|
||||
@Qualifier("terminalWebSocketExecutor") ExecutorService executor) {
|
||||
this.connectionRepository = connectionRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.connectionService = connectionService;
|
||||
this.sshService = sshService;
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession webSocketSession) throws Exception {
|
||||
Long connectionId = (Long) webSocketSession.getAttributes().get("connectionId");
|
||||
String username = (String) webSocketSession.getAttributes().get("username");
|
||||
|
||||
if (connectionId == null || username == null) {
|
||||
webSocketSession.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
User user = userRepository.findByUsername(username).orElse(null);
|
||||
if (user == null) {
|
||||
webSocketSession.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
Connection conn = connectionRepository.findById(connectionId).orElse(null);
|
||||
if (conn == null || !conn.getUserId().equals(user.getId())) {
|
||||
webSocketSession.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
String password = connectionService.getDecryptedPassword(conn);
|
||||
String privateKey = connectionService.getDecryptedPrivateKey(conn);
|
||||
String passphrase = connectionService.getDecryptedPassphrase(conn);
|
||||
|
||||
try {
|
||||
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
|
||||
sessions.put(webSocketSession.getId(), sshSession);
|
||||
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||
sessionCount.incrementAndGet();
|
||||
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
InputStream in = sshSession.getOutputStream();
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while (webSocketSession.isOpen() && sshSession.isConnected() && (n = in.read(buf)) >= 0) {
|
||||
String text = new String(buf, 0, n, "UTF-8");
|
||||
webSocketSession.sendMessage(new TextMessage(text));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (webSocketSession.isOpen()) {
|
||||
try {
|
||||
webSocketSession.sendMessage(new TextMessage("\r\n[Connection closed]\r\n"));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
webSocketSession.sendMessage(new TextMessage("\r\n[SSH Error: " + e.getMessage() + "]\r\n"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
|
||||
@SuppressWarnings("null")
|
||||
public void afterConnectionEstablished(@NonNull WebSocketSession webSocketSession) throws Exception {
|
||||
Long connectionId = (Long) webSocketSession.getAttributes().get("connectionId");
|
||||
String username = (String) webSocketSession.getAttributes().get("username");
|
||||
|
||||
if (connectionId == null || username == null) {
|
||||
webSocketSession.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
User user = userRepository.findByUsername(username).orElse(null);
|
||||
if (user == null) {
|
||||
webSocketSession.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
Connection conn = connectionRepository.findById(connectionId).orElse(null);
|
||||
if (conn == null || !conn.getUserId().equals(user.getId())) {
|
||||
webSocketSession.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
String password = connectionService.getDecryptedPassword(conn);
|
||||
String privateKey = connectionService.getDecryptedPrivateKey(conn);
|
||||
String passphrase = connectionService.getDecryptedPassphrase(conn);
|
||||
|
||||
try {
|
||||
SshService.SshSession sshSession = sshService.createShellSession(conn, password, privateKey, passphrase);
|
||||
sessions.put(webSocketSession.getId(), sshSession);
|
||||
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||
sessionCount.incrementAndGet();
|
||||
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
InputStream in = sshSession.getOutputStream();
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while (webSocketSession.isOpen() && sshSession.isConnected() && (n = in.read(buf)) >= 0) {
|
||||
String text = new String(buf, 0, n, "UTF-8");
|
||||
webSocketSession.sendMessage(new TextMessage(text));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (webSocketSession.isOpen()) {
|
||||
try {
|
||||
webSocketSession.sendMessage(new TextMessage("\r\n[Connection closed]\r\n"));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
webSocketSession.sendMessage(new TextMessage("\r\n[SSH Error: " + e.getMessage() + "]\r\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(@NonNull WebSocketSession webSocketSession, @NonNull TextMessage message) throws Exception {
|
||||
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
|
||||
if (sshSession != null && sshSession.isConnected()) {
|
||||
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
||||
@@ -120,14 +122,14 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
sshSession.getInputStream().flush();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception {
|
||||
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
|
||||
lastActivity.remove(webSocketSession.getId());
|
||||
if (sshSession != null) {
|
||||
sshSession.disconnect();
|
||||
sessionCount.decrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(@NonNull WebSocketSession webSocketSession, @NonNull CloseStatus status) throws Exception {
|
||||
SshService.SshSession sshSession = sessions.remove(webSocketSession.getId());
|
||||
lastActivity.remove(webSocketSession.getId());
|
||||
if (sshSession != null) {
|
||||
sshSession.disconnect();
|
||||
sessionCount.decrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
package com.sshmanager.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(length = 100)
|
||||
private String displayName;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant updatedAt = Instant.now();
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant passwordChangedAt;
|
||||
}
|
||||
package com.sshmanager.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(length = 100)
|
||||
private String displayName;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant updatedAt = Instant.now();
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant passwordChangedAt;
|
||||
}
|
||||
|
||||
@@ -1,65 +1,66 @@
|
||||
package com.sshmanager.security;
|
||||
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final UserDetailsService userDetailsService;
|
||||
|
||||
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, UserDetailsService userDetailsService) {
|
||||
this.tokenProvider = tokenProvider;
|
||||
this.userDetailsService = userDetailsService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
try {
|
||||
String jwt = getJwtFromRequest(request);
|
||||
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
|
||||
String username = tokenProvider.getUsernameFromToken(jwt);
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// Log but don't fail - let controller handle 401
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String getJwtFromRequest(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader("Authorization");
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
// WebSocket handshake and SSE endpoints send token as query param
|
||||
String uri = request.getRequestURI();
|
||||
if (uri != null && (uri.startsWith("/ws/") || uri.contains("/progress"))) {
|
||||
String token = request.getParameter("token");
|
||||
if (StringUtils.hasText(token)) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
package com.sshmanager.security;
|
||||
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.lang.NonNull;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final UserDetailsService userDetailsService;
|
||||
|
||||
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, UserDetailsService userDetailsService) {
|
||||
this.tokenProvider = tokenProvider;
|
||||
this.userDetailsService = userDetailsService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain) throws ServletException, IOException {
|
||||
try {
|
||||
String jwt = getJwtFromRequest(request);
|
||||
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
|
||||
String username = tokenProvider.getUsernameFromToken(jwt);
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// Log but don't fail - let controller handle 401
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String getJwtFromRequest(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader("Authorization");
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
// WebSocket handshake and SSE endpoints send token as query param
|
||||
String uri = request.getRequestURI();
|
||||
if (uri != null && (uri.startsWith("/ws/") || uri.contains("/progress"))) {
|
||||
String token = request.getParameter("token");
|
||||
if (StringUtils.hasText(token)) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.lang.NonNull;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
@@ -29,7 +30,7 @@ public class PasswordExpirationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
|
||||
@@ -59,6 +59,7 @@ public class BackupService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@SuppressWarnings("null")
|
||||
public BackupImportResponseDto importBackup(Long userId, BackupPackageDto backupPackage) {
|
||||
if (backupPackage == null) {
|
||||
throw new IllegalArgumentException("Backup package is required");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.sshmanager.dto.ConnectionCreateRequest;
|
||||
import com.sshmanager.dto.ConnectionDto;
|
||||
import com.sshmanager.entity.Connection;
|
||||
@@ -9,14 +9,15 @@ import com.sshmanager.exception.NotFoundException;
|
||||
import com.sshmanager.repository.ConnectionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ConnectionService {
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@SuppressWarnings("null")
|
||||
public class ConnectionService {
|
||||
|
||||
private final ConnectionRepository connectionRepository;
|
||||
private final EncryptionService encryptionService;
|
||||
private final SshService sshService;
|
||||
@@ -31,13 +32,13 @@ public class ConnectionService {
|
||||
this.sshService = sshService;
|
||||
this.sshBootstrapService = sshBootstrapService;
|
||||
}
|
||||
|
||||
public List<ConnectionDto> listByUserId(Long userId) {
|
||||
return connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId).stream()
|
||||
.map(ConnectionDto::fromEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
public List<ConnectionDto> listByUserId(Long userId) {
|
||||
return connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId).stream()
|
||||
.map(ConnectionDto::fromEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public ConnectionDto getById(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElseThrow(
|
||||
() -> new NotFoundException("Connection not found: " + id));
|
||||
@@ -101,7 +102,7 @@ public class ConnectionService {
|
||||
conn = connectionRepository.save(conn);
|
||||
return ConnectionDto.fromEntity(conn);
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, Long userId) {
|
||||
Connection conn = connectionRepository.findById(id).orElse(null);
|
||||
@@ -122,30 +123,30 @@ public class ConnectionService {
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
public String getDecryptedPassword(Connection conn) {
|
||||
return conn.getEncryptedPassword() != null ?
|
||||
encryptionService.decrypt(conn.getEncryptedPassword()) : null;
|
||||
}
|
||||
|
||||
public String getDecryptedPrivateKey(Connection conn) {
|
||||
return conn.getEncryptedPrivateKey() != null ?
|
||||
encryptionService.decrypt(conn.getEncryptedPrivateKey()) : null;
|
||||
}
|
||||
|
||||
public String getDecryptedPassphrase(Connection conn) {
|
||||
return conn.getPassphrase() != null ?
|
||||
encryptionService.decrypt(conn.getPassphrase()) : null;
|
||||
}
|
||||
|
||||
public Connection testConnection(Connection conn, String password, String privateKey, String passphrase) {
|
||||
SshService.SshSession session = null;
|
||||
try {
|
||||
session = sshService.createShellSession(conn, password, privateKey, passphrase);
|
||||
return conn;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Connection test failed: " + e.getMessage(), e);
|
||||
} finally {
|
||||
|
||||
public String getDecryptedPassword(Connection conn) {
|
||||
return conn.getEncryptedPassword() != null ?
|
||||
encryptionService.decrypt(conn.getEncryptedPassword()) : null;
|
||||
}
|
||||
|
||||
public String getDecryptedPrivateKey(Connection conn) {
|
||||
return conn.getEncryptedPrivateKey() != null ?
|
||||
encryptionService.decrypt(conn.getEncryptedPrivateKey()) : null;
|
||||
}
|
||||
|
||||
public String getDecryptedPassphrase(Connection conn) {
|
||||
return conn.getPassphrase() != null ?
|
||||
encryptionService.decrypt(conn.getPassphrase()) : null;
|
||||
}
|
||||
|
||||
public Connection testConnection(Connection conn, String password, String privateKey, String passphrase) {
|
||||
SshService.SshSession session = null;
|
||||
try {
|
||||
session = sshService.createShellSession(conn, password, privateKey, passphrase);
|
||||
return conn;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Connection test failed: " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (session != null) {
|
||||
session.disconnect();
|
||||
}
|
||||
|
||||
@@ -28,17 +28,13 @@ import java.util.Optional;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@SuppressWarnings("null")
|
||||
class ConnectionControllerTest {
|
||||
|
||||
@Mock
|
||||
|
||||
@@ -45,6 +45,7 @@ import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@SuppressWarnings("null")
|
||||
class SftpControllerTest {
|
||||
|
||||
@Mock
|
||||
|
||||
@@ -26,6 +26,7 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@SuppressWarnings("null")
|
||||
class BackupServiceTest {
|
||||
|
||||
@Mock
|
||||
|
||||
@@ -27,6 +27,7 @@ import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@SuppressWarnings("null")
|
||||
class ConnectionServiceTest {
|
||||
|
||||
@Mock
|
||||
|
||||
@@ -23,6 +23,7 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@SuppressWarnings("null")
|
||||
class SessionTreeLayoutServiceTest {
|
||||
|
||||
@Mock
|
||||
|
||||
@@ -110,12 +110,12 @@ export default function TransferCenterModal({
|
||||
const unsubscribe = subscribeUploadProgress(taskId, (task) => {
|
||||
updateTaskGroup(groupId, (current) => {
|
||||
const nextItems = current.items.map((item) =>
|
||||
item.taskId === task.taskId
|
||||
item.taskId === task.taskId
|
||||
? {
|
||||
...item,
|
||||
progress: task.progress,
|
||||
status: task.status,
|
||||
message: task.error || (task.status === 'success' ? '上传完成' : task.status === 'error' ? '上传失败' : task.status === 'cancelled' ? '已取消' : '正在传输...'),
|
||||
message: task.error || (task.status === 'success' ? '上传完成' : task.status === 'error' ? '上传失败' : '正在传输...'),
|
||||
}
|
||||
: item,
|
||||
)
|
||||
@@ -231,9 +231,9 @@ export default function TransferCenterModal({
|
||||
<div className="rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-slate-400">按浏览器任务并行执行</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<span className="text-sm text-slate-300">3. 目标服务器</span>
|
||||
<div className="rounded-2xl border border-slate-700 bg-black p-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-2">
|
||||
<span className="shrink-0 text-sm text-slate-300">3. 目标服务器</span>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-slate-700 bg-black p-2">
|
||||
{connections.map((server) => {
|
||||
const st = connectionStatuses[server.id]
|
||||
const isOnline = st === 'online'
|
||||
|
||||
Reference in New Issue
Block a user