This commit is contained in:
2026-04-29 19:04:48 +08:00
parent 543c4d9096
commit ac6442a452
43 changed files with 1104 additions and 489 deletions
@@ -7,19 +7,11 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MyMvcConfig implements WebMvcConfigurer {
/*
* addResourceHandlers
* void
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("swagger-ui.html")
.addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
// SpringDoc OpenAPI serves its own UI resources — no manual mapping needed.
// Static application assets:
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/");
}
}
@@ -1,31 +1,29 @@
package com.yaoyuan.jiscuss.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
/**
* SpringDoc OpenAPI 2.x configuration.
*
* <p>Replaces discontinued springfox-swagger2 (incompatible with Spring Boot 3).
* UI is available at /swagger-ui/index.html
*/
@Configuration
@EnableSwagger2
public class SwaggerConfiguration {
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select()
.apis(RequestHandlerSelectors.basePackage("com.yaoyuan.jiscuss.controller")).paths(PathSelectors.any())
.build();
public OpenAPI jiscussOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Jiscuss REST APIs")
.description("Jiscuss forum system API documentation")
.version("1.0")
.contact(new Contact()
.name("Jiscuss Team")
.email("developer@mail.com")));
}
@SuppressWarnings("deprecation")
private ApiInfo apiInfo() {
return new ApiInfoBuilder().title("swagger-ui RESTful APIs").description("Jiscuss")
.termsOfServiceUrl("http://localhost/").contact("developer@mail.com").version("1.0").build();
}
}
}
@@ -1,104 +1,157 @@
package com.yaoyuan.jiscuss.config;
import com.yaoyuan.jiscuss.handler.CustomAccessDeniedHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Configurable;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler;
import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager;
/**
* prePostEnabled :决定Spring Security的前注解是否可用 [@PreAuthorize,@PostAuthorize,..]
* secureEnabled : 决定是否Spring Security的保障注解 [@Secured] 是否可用
* jsr250Enabled :决定 JSR-250 annotations 注解[@RolesAllowed..] 是否可用.
* 开启 Spring Security 方法级安全注解 @EnableGlobalMethodSecurity
* Spring Security 6 configuration (Spring Boot 3 compatible).
*
* <p>Breaking changes from Spring Boot 2.x:
* <ul>
* <li>{@code WebSecurityConfigurerAdapter} removed → use {@code SecurityFilterChain} bean</li>
* <li>{@code antMatchers} → {@code requestMatchers}</li>
* <li>{@code @EnableGlobalMethodSecurity} → {@code @EnableMethodSecurity}</li>
* <li>{@code @Configurable} (AspectJ) corrected to {@code @Configuration}</li>
* </ul>
*/
@Configurable
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfig {
@Autowired
private CustomAccessDeniedHandler customAccessDeniedHandler;
@Qualifier("userDetailServiceImpl")
@Autowired
private UserDetailsService userDetailsService;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final UserDetailsService userDetailsService;
private final ApplicationContext applicationContext;
/** Controlled by spring.h2.console.enabled — only true in dev (h2) profile. */
@Value("${spring.h2.console.enabled:false}")
private boolean h2ConsoleEnabled;
public WebSecurityConfig(
CustomAccessDeniedHandler customAccessDeniedHandler,
@Qualifier("userDetailServiceImpl") UserDetailsService userDetailsService,
ApplicationContext applicationContext) {
this.customAccessDeniedHandler = customAccessDeniedHandler;
this.userDetailsService = userDetailsService;
this.applicationContext = applicationContext;
}
/**
* 静态资源设置
* Exclude truly static assets from the security filter chain entirely.
* H2 console and Druid monitoring are NOT excluded here — they are secured via filterChain.
*/
@Override
public void configure(WebSecurity webSecurity) {
//不拦截静态资源,所有用户均可访问的资源
webSecurity.ignoring().antMatchers(
"/",
"/css/**",
"/js/**",
"/images/**",
"/static/**",
"/h2-console/**",
"/test/**",
"/druid/**"
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring().requestMatchers(
"/css/**", "/js/**", "/images/**", "/static/**", "/favicon.ico"
);
}
/**
* http请求设置
* Main security filter chain.
*
* <p>Security decisions:
* <ul>
* <li>H2 console: only accessible when enabled (dev profile) and requires ROLE_ADMIN</li>
* <li>Druid monitoring: requires ROLE_ADMIN (Druid also has its own login page)</li>
* <li>Admin/Actuator paths: requires ROLE_ADMIN</li>
* <li>SpringDoc UI paths: public (API docs are for developers)</li>
* <li>All other authenticated requests: evaluated by {@code RbacPermission.hasPermission}</li>
* <li>X-Frame-Options: SAMEORIGIN (prevents clickjacking while allowing H2 console iframe)</li>
* </ul>
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//http.csrf().disable(); //注释就是使用 csrf 功能
http.headers().frameOptions().disable();//解决 in a frame because it set 'X-Frame-Options' to 'DENY' 问题
//http.anonymous().disable();
http.authorizeRequests()
.antMatchers("/login/**", "/initUserData")//不拦截登录相关方法
.permitAll()
//.antMatchers("/user").hasRole("ADMIN") // user接口只有ADMIN角色的可以访问
//.anyRequest()
//.authenticated()// 任何尚未匹配的URL只需要验证用户即可访问
.anyRequest()
.access("@rbacPermission.hasPermission(request, authentication)")//根据账号权限访问
.and()
.formLogin()
//.loginPage("/")
.loginPage("/login") //登录请求页
.loginProcessingUrl("/login") //登录POST请求路径
.usernameParameter("username") //登录用户名参数
.passwordParameter("password") //登录密码参数
.defaultSuccessUrl("/index") //默认登录成功页面
.and()
.exceptionHandling()
.accessDeniedHandler(customAccessDeniedHandler) //无权限处理器
.and()
.logout()
.logoutSuccessUrl("/login?logout"); //退出登录成功URL
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// Fix: SAMEORIGIN instead of disable() — prevents clickjacking, allows H2 iframe in same origin
http.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin())
);
// CSRF: disable for H2 console path only (H2 web console does not send CSRF tokens)
if (h2ConsoleEnabled) {
http.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**"));
}
// Build the RBAC expression manager with ApplicationContext so @bean references work
DefaultWebSecurityExpressionHandler expressionHandler = new DefaultWebSecurityExpressionHandler();
expressionHandler.setApplicationContext(applicationContext);
WebExpressionAuthorizationManager rbacManager = new WebExpressionAuthorizationManager(
"@rbacPermission.hasPermission(request, authentication)");
rbacManager.setExpressionHandler(expressionHandler);
http.authorizeHttpRequests(auth -> {
// Publicly accessible
auth.requestMatchers(
"/login/**", "/initUserData",
// SpringDoc OpenAPI UI and spec endpoint (developer tools, no sensitive data)
"/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**"
).permitAll();
// H2 console: admin-only, dev profile only
if (h2ConsoleEnabled) {
auth.requestMatchers("/h2-console/**").hasRole("ADMIN");
}
// Druid monitoring, Actuator, Admin UI: restricted to ROLE_ADMIN
auth.requestMatchers("/druid/**").hasRole("ADMIN");
auth.requestMatchers("/actuator/**").hasRole("ADMIN");
auth.requestMatchers("/admin/**").hasRole("ADMIN");
// All remaining requests evaluated by RBAC permission bean
auth.anyRequest().access(rbacManager);
});
http.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.defaultSuccessUrl("/index")
);
http.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
);
http.exceptionHandling(ex -> ex
.accessDeniedHandler(customAccessDeniedHandler)
);
return http.build();
}
/**
* 自定义获取用户信息接口
* Authentication manager wired with BCrypt — replaces the old
* {@code configure(AuthenticationManagerBuilder)} override.
*/
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
@Bean
public AuthenticationManager authenticationManager(BCryptPasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(provider);
}
/**
* 密码加密算法
* @return
*/
/** BCrypt password encoder bean. */
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -6,7 +6,7 @@ import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequest;
/**
* @author yaoyuan2.chu
@@ -4,8 +4,8 @@ import com.yaoyuan.jiscuss.entity.UserInfo;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.util.Enumeration;
/**
@@ -12,7 +12,7 @@ import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequest;
/**
* @author yaoyuan2.chu
@@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.List;
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
@@ -4,8 +4,8 @@ import com.yaoyuan.jiscuss.entity.Discussion;
import com.yaoyuan.jiscuss.exception.BaseException;
import com.yaoyuan.jiscuss.response.ResponseCode;
import com.yaoyuan.jiscuss.service.IDiscussionsService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -20,15 +20,16 @@ import java.util.List;
@RestController
@RequestMapping(path = "/other_api", produces = "application/json;charset=utf-8")
@Api(value = "主题帖子(其他)接口管理", tags = {"主题帖子(其他)接口管理"})
@Tag(name = "主题帖子(其他)接口管理", description = "主题相关 REST API")
public class RestPostController {
private static Logger logger = LoggerFactory.getLogger(RestPostController.class);
private static final Logger logger = LoggerFactory.getLogger(RestPostController.class);
@Autowired
private IDiscussionsService discussionsService;
@PostMapping("/discussion")
@ApiOperation("新增主题")
@Operation(summary = "新增主题")
public Discussion save(@RequestBody Discussion discussion) {
Discussion saveDiscussion = discussionsService.insert(discussion);
if (saveDiscussion != null) {
@@ -39,18 +40,16 @@ public class RestPostController {
}
@GetMapping("/discussion/{id}")
@ApiOperation("获取主题")
@Operation(summary = "获取主题")
public Discussion getDiscussions(@PathVariable Integer id) {
Discussion discussion = discussionsService.findOne(id);
return discussion;
return discussionsService.findOne(id);
}
@GetMapping("/discussions")
@ApiOperation("获取全部主题")
@Operation(summary = "获取全部主题")
public List<Discussion> getAllDiscussions() {
List<Discussion> allDiscussions = discussionsService.getAllList();
logger.info("全部主题==>{}", allDiscussions);
return allDiscussions;
}
}
@@ -4,9 +4,8 @@ import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.exception.BaseException;
import com.yaoyuan.jiscuss.response.ResponseCode;
import com.yaoyuan.jiscuss.service.IUsersService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.json.JSONObject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -23,16 +22,16 @@ import java.util.List;
@RestController
@RequestMapping("/user_api")
@Api(value = "用户接口管理", tags = {"用户接口管理"})
@Tag(name = "用户接口管理", description = "用户 CRUD API")
public class RestUserController {
private static Logger logger = LoggerFactory.getLogger(RestUserController.class);
private static final Logger logger = LoggerFactory.getLogger(RestUserController.class);
@Autowired
private IUsersService usersService;
@PostMapping("/user")
@ApiOperation("新增用户")
@Operation(summary = "新增用户")
public User save(@RequestBody User user) {
User saveUser = usersService.insert(user);
if (saveUser != null) {
@@ -40,23 +39,17 @@ public class RestUserController {
} else {
throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST);
}
}
@DeleteMapping("/user/{id}")
@ApiOperation("删除用户")
@Operation(summary = "删除用户")
public Boolean delete(@PathVariable Integer id) {
Boolean delete = true;
usersService.remove(id);
if (delete) {
return delete;
} else {
throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST);
}
return true;
}
@PutMapping("/user/{id}")
@ApiOperation("修改用户")
@Operation(summary = "修改用户")
public User update(@RequestBody User user, @PathVariable Integer id) {
User updateuser = usersService.update(user, id);
if (updateuser != null) {
@@ -67,19 +60,19 @@ public class RestUserController {
}
@GetMapping("/user/{id}")
@ApiOperation("获取用户")
@Operation(summary = "获取用户")
public User getUser(@PathVariable Integer id) {
User user = usersService.findOne(id);
logger.info("获取用户==>{}", JSONObject.valueToString(user));
logger.info("获取用户==>{}", user);
return user;
}
@GetMapping("/user")
@ApiOperation("获取全部用户")
public List<User> getUser(User user) {
List<User> userall = usersService.getAllList();
logger.info("全部用户==>{}", JSONObject.valueToString(userall));
return userall;
@Operation(summary = "获取全部用户")
public List<User> getAllUsers() {
return usersService.getAllList();
}
}
}
@@ -0,0 +1,31 @@
package com.yaoyuan.jiscuss.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Request payload for creating a new user account.
* Validated at the controller boundary before reaching the service layer.
*/
public record UserCreateRequest(
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 50, message = "用户名长度必须在 3-50 个字符之间")
String username,
/**
* Raw password — will be BCrypt-hashed before persistence.
* Never log or return this field.
*/
@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 100, message = "密码长度至少 8 个字符")
String password,
@Email(message = "邮箱格式不正确")
@Size(max = 100)
String email,
@Size(max = 50)
String realname
) {}
@@ -0,0 +1,44 @@
package com.yaoyuan.jiscuss.dto;
import com.yaoyuan.jiscuss.entity.User;
import org.springframework.stereotype.Component;
/**
* Manual mapper between {@link User} entity and DTO objects.
*
* <p>Intentionally excludes the {@code password} field from all output DTOs.
* Replace with a generated MapStruct mapper if mapping complexity grows.
*/
@Component
public class UserMapper {
/** Map a {@link User} entity to a safe {@link UserResponse} (no password). */
public UserResponse toResponse(User user) {
if (user == null) return null;
return new UserResponse(
user.getId(),
user.getUsername(),
user.getRealname(),
user.getEmail(),
user.getAvatar(),
user.getGender(),
user.getPhone(),
user.getAge(),
user.getDiscussionsCount(),
user.getCommentsCount(),
user.getJoinTime(),
user.getLastSeenTime(),
user.getLevel()
);
}
/** Map a {@link UserCreateRequest} to a new {@link User} entity (password left unset — caller must hash it). */
public User fromCreateRequest(UserCreateRequest request) {
User user = new User();
user.setUsername(request.username());
user.setEmail(request.email());
user.setRealname(request.realname());
// Caller is responsible for BCrypt-hashing the password before calling usersService.insert()
return user;
}
}
@@ -0,0 +1,23 @@
package com.yaoyuan.jiscuss.dto;
import java.util.Date;
/**
* Read-only user projection exposed to the frontend / API consumers.
* Never exposes the {@code password} field.
*/
public record UserResponse(
Integer id,
String username,
String realname,
String email,
String avatar,
String gender,
String phone,
Integer age,
Integer discussionsCount,
Integer commentsCount,
Date joinTime,
Date lastSeenTime,
Integer level
) {}
@@ -1,13 +1,15 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;
@@ -22,6 +24,8 @@ public class Discussion implements Serializable {
@Column(name = "id", unique = true)
private Integer id;
@NotBlank
@Size(max = 200)
@Column(name = "title")
private String title;
@@ -64,7 +68,7 @@ public class Discussion implements Serializable {
@Column(name = "like_count")
private Integer likeCount;
/** IP is resolved server-side via IpUtils.getClientIp() — never trusted from X-Forwarded-For alone. */
@Column(name = "ip_address")
private String ipAddress;
@@ -73,5 +77,5 @@ public class Discussion implements Serializable {
@Column(name = "create_time")
private Date createTime;
}
@@ -1,13 +1,13 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
@Data
@@ -1,22 +1,18 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;
/**
* @author yaoyuan2.chu
* @Title:
* @Package com.yaoyuan.jiscuss.entity
* @Description:
* @date 2020/10/21 12:00
*/
@Data
@Entity
@@ -1,13 +1,14 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;
@@ -36,6 +37,7 @@ public class Post implements Serializable {
@Column(name = "type")
private String type;
@NotBlank
@Column(name = "content")
private String content;
@@ -48,6 +50,7 @@ public class Post implements Serializable {
@Column(name = "edit_user_id")
private Integer editUserId;
/** IP resolved server-side via IpUtils.getClientIp(). */
@Column(name = "ip_address")
private String ipAddress;
@@ -1,13 +1,13 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
@Data
@@ -1,13 +1,13 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;
@@ -1,14 +1,17 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;
@@ -22,15 +25,21 @@ public class User implements Serializable {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@NotBlank
@Size(min = 3, max = 50)
@Column(name = "username")
private String username;
@Size(max = 50)
@Column(name = "realname")
private String realname;
@Email
@Size(max = 100)
@Column(name = "email")
private String email;
/** BCrypt-hashed password. Column length must be >= 68 (see V2 migration). */
@Column(name = "password")
private String password;
@@ -40,12 +49,14 @@ public class User implements Serializable {
@Column(name = "age")
private Integer age;
@Pattern(regexp = "^(男|女|其他)?$")
@Column(name = "gender")
private String gender;
@Column(name = "avatar")
private String avatar;
@Size(max = 20)
@Column(name = "phone")
private String phone;
@@ -63,5 +74,4 @@ public class User implements Serializable {
@Column(name = "level")
private Integer level;
}
}
@@ -4,7 +4,7 @@ import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import jakarta.persistence.Column;
/**
* @author yaoyuan2.chu
@@ -0,0 +1,80 @@
package com.yaoyuan.jiscuss.exception;
import com.yaoyuan.jiscuss.response.ApiResponse;
import com.yaoyuan.jiscuss.response.ResponseCode;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
/**
* Global exception handler.
*
* <p>Catches {@link BaseException}, validation errors, and unexpected exceptions so they
* are returned as structured JSON instead of Spring's default error page.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/** Business logic exceptions (already typed with a ResponseCode). */
@ExceptionHandler(BaseException.class)
public ResponseEntity<ApiResponse<Void>> handleBaseException(BaseException ex) {
log.warn("Business exception: code={}, msg={}", ex.getCode().getCode(), ex.getCode().getMsg());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.fail(ex.getCode()));
}
/** @Valid / @Validated failures on @RequestBody. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.joining("; "));
log.warn("Validation failed: {}", message);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(message));
}
/** @Validated failures on @RequestParam / @PathVariable. */
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(ConstraintViolationException ex) {
String message = ex.getConstraintViolations().stream()
.map(cv -> cv.getPropertyPath() + ": " + cv.getMessage())
.collect(Collectors.joining("; "));
log.warn("Constraint violation: {}", message);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(message));
}
/**
* Spring Security access-denied exceptions re-thrown by method security.
* Note: exceptions raised before method invocation are handled by {@code CustomAccessDeniedHandler}.
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("没有权限访问该资源"));
}
/** Catch-all for any unhandled exception — never expose stack traces to clients. */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneral(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail(ResponseCode.SERVICE_ERROR));
}
}
@@ -8,10 +8,10 @@ import org.springframework.security.web.WebAttributes;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
@@ -1,37 +1,70 @@
package com.yaoyuan.jiscuss.handler;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import javax.servlet.http.HttpServletRequest;
import java.util.Collection;
/**
* RBAC数据模型控制权限
* RBAC (Role-Based Access Control) permission evaluator.
*
* @author charlie
* <p>Rules:
* <ul>
* <li>Anonymous / unauthenticated: denied</li>
* <li>ROLE_ADMIN: full access (all paths)</li>
* <li>ROLE_USER: access to all non-admin paths</li>
* <li>Admin-only paths ({@code /admin/**}, {@code /druid/**}, etc.) require ROLE_ADMIN</li>
* </ul>
*
* <p>Previously this method unconditionally returned {@code true}, giving every user
* unrestricted access regardless of role — a critical security vulnerability now fixed.
*/
@Component("rbacPermission")
public class RbacPermission {
private AntPathMatcher antPathMatcher = new AntPathMatcher();
private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
/** URL patterns that require ROLE_ADMIN. */
private static final String[] ADMIN_ONLY_PATTERNS = {
"/admin/**",
"/druid/**",
"/actuator/**",
"/h2-console/**",
"/user_api/**",
"/other_api/**"
};
/**
* Evaluates whether the authenticated principal is permitted to access the requested URL.
*
* @param request the current HTTP request
* @param authentication the current authentication token
* @return {@code true} when access is granted
*/
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
Object principal = authentication.getPrincipal();
boolean hasPermission = false;
if (authentication == null
|| !authentication.isAuthenticated()
|| "anonymousUser".equals(authentication.getPrincipal())) {
return false;
}
hasPermission = true;
// if (principal instanceof UserEntity) {
// // 读取用户所拥有的权限菜单
// List<Menu> menus = ((UserEntity) principal).getRoleMenus();
// System.out.println(menus.size());
// for (Menu menu : menus) {
// if (antPathMatcher.match(menu.getMenuUrl(), request.getRequestURI())) {
// hasPermission = true;
// break;
// }
// }
// }
return hasPermission;
String uri = request.getRequestURI();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
boolean isAdmin = authorities.stream()
.anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
// Admin-only paths: only ROLE_ADMIN may proceed
for (String pattern : ADMIN_ONLY_PATTERNS) {
if (PATH_MATCHER.match(pattern, uri)) {
return isAdmin;
}
}
// All other paths are accessible by any authenticated user
return true;
}
}
@@ -30,11 +30,6 @@ public interface UsersRepository extends JpaRepository<User, Integer> {
@Query("from User where username = :username")
User getByUsername(String username);
/**
* @param username
* @param password
* @return
*/
@Query("from User where username = :username and password = :password")
User checkByUsernameAndPassword(String username, String password);
// REMOVED: checkByUsernameAndPassword — never compare passwords at the SQL layer.
// Password validation must go through BCryptPasswordEncoder.matches() in the service/security layer.
}
@@ -0,0 +1,21 @@
package com.yaoyuan.jiscuss.response;
/**
* Unified API response wrapper.
*
* @param <T> payload type
*/
public record ApiResponse<T>(int code, String msg, T data) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(ResponseCode.SUCCESS.getCode(), ResponseCode.SUCCESS.getMsg(), data);
}
public static <T> ApiResponse<T> fail(ResponseCode responseCode) {
return new ApiResponse<>(responseCode.getCode(), responseCode.getMsg(), null);
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(ResponseCode.SERVICE_ERROR.getCode(), message, null);
}
}
@@ -24,7 +24,5 @@ public interface IUsersService {
User getByUsername(String username);
User checkByUsernameAndPassword(String username, String password);
User update(User user, Integer id);
}
@@ -10,54 +10,49 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.transaction.Transactional;
import java.util.List;
@Service
@Transactional
public class DiscussionsServiceImpl implements IDiscussionsService {
@Autowired
private DiscussionsRepository discussionsRepository;
@Transactional(readOnly = true)
@Override
public List<Discussion> getAllList() {
return discussionsRepository.findAll();
}
@Transactional
@Override
public Discussion insert(Discussion discussion) {
return discussionsRepository.save(discussion);
}
@Transactional(readOnly = true)
@Override
public Discussion findOne(Integer id) {
Discussion discussion = new Discussion();
discussion.setId(id);
return discussionsRepository.getOne(id);
// getReferenceById replaces removed JpaRepository.getOne()
return discussionsRepository.getReferenceById(id);
}
@Transactional(readOnly = true)
@Override
public Page<Discussion> queryAllDiscussionsList(Discussion discussion, int pageNum, int pageSize, String tag, String type) {
Sort sort = new Sort(Sort.Direction.DESC, "id");
if (type.equals("hot")) {
sort = new Sort(Sort.Direction.DESC, "likeCount");
} else if (type.equals("new")) {
sort = new Sort(Sort.Direction.DESC, "startTime");
}
@SuppressWarnings("deprecation")
Pageable pageable = new PageRequest(pageNum, pageSize, sort);
//将匹配对象封装成Example对象
Sort sort = switch (type) {
case "hot" -> Sort.by(Sort.Direction.DESC, "likeCount");
case "new" -> Sort.by(Sort.Direction.DESC, "startTime");
default -> Sort.by(Sort.Direction.DESC, "id");
};
Pageable pageable = PageRequest.of(pageNum, pageSize, sort);
Example<Discussion> example = Example.of(discussion);
if (null != tag && !"all".equals(tag)) {
Page<Discussion> pageList = discussionsRepository.findByQuery(tag, pageable);
return pageList;
if (tag != null && !"all".equals(tag)) {
return discussionsRepository.findByQuery(tag, pageable);
} else {
Page<Discussion> pageList = discussionsRepository.findAll(example, pageable);
return pageList;
return discussionsRepository.findAll(example, pageable);
}
}
}
@@ -6,21 +6,22 @@ import com.yaoyuan.jiscuss.service.IDiscussionsTagsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class DiscussionsTagsServiceImpl implements IDiscussionsTagsService {
@Autowired
private DiscussionsTagsRepository discussionsTagsRepository;
@Transactional(readOnly = true)
@Override
public List<DiscussionTag> getAllList() {
return discussionsTagsRepository.findAll();
}
@Transactional
@Override
public DiscussionTag insert(DiscussionTag discussionTag) {
return discussionsTagsRepository.save(discussionTag);
@@ -11,30 +11,32 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Example;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
@Transactional
public class PostsServiceImpl implements IPostsService {
@Autowired
private PostsRepository postsRepository;
@Transactional(readOnly = true)
@Override
public List<Post> getAllList() {
return postsRepository.findAll();
}
@Transactional(readOnly = true)
@Override
public List<Post> findOneBy(Integer id) {
List<Post> posts = postsRepository.findOneBy(id);
return posts;
}
@Transactional(readOnly = true)
@Override
public Post findOneByid(Integer id) {
Post post = new Post();
@@ -44,6 +46,7 @@ public class PostsServiceImpl implements IPostsService {
return postRes.get();
}
@Transactional(readOnly = true)
@Override
public List findPostCustomById(Integer id) {
//查询id为1且parentId为null的评论
@@ -76,6 +79,7 @@ public class PostsServiceImpl implements IPostsService {
return list;
}
@Transactional
@Override
public Post insert(Post post) {
return postsRepository.save(post);
@@ -6,20 +6,21 @@ import com.yaoyuan.jiscuss.service.ISettingsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class SettingsServiceImpl implements ISettingsService {
@Autowired
private SettingsRepository settingsRepository;
@Transactional(readOnly = true)
@Override
public List<Setting> getAllList() {
return settingsRepository.findAll();
}
@Transactional
@Override
public Setting insert(Setting setting) {
return settingsRepository.save(setting);
@@ -7,40 +7,45 @@ import com.yaoyuan.jiscuss.service.ITagsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class TagsServiceImpl implements ITagsService {
@Autowired
private TagsRepository tagsRepository;
@Transactional(readOnly = true)
@Override
public List<Tag> getAllList() {
return tagsRepository.findAllIsNull();
}
@Transactional(readOnly = true)
@Override
public List<Tag> getAllListDiscussions() {
return tagsRepository.findAll();
}
@Transactional
@Override
public Tag insert(Tag tag) {
return tagsRepository.save(tag);
}
@Transactional(readOnly = true)
@Override
public List<Tag> findByDId(Integer id) {
return tagsRepository.findByDId(id);
}
@Transactional(readOnly = true)
@Override
public List<TagCustom> findByDiscussionIdlistId(List<Integer> discussionIdLsit) {
return tagsRepository.findByDiscussionIdlistId(discussionIdLsit);
}
@Transactional(readOnly = true)
@Override
public List<Tag> findByParentId(String tag) {
int tagId = Integer.parseInt(tag);
@@ -8,53 +8,46 @@ import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* @author yaoyuan2.chu
* @Title:
* @Package com.yaoyuan.jiscuss.service.impl
* @Description:
* @date 2020/7/15 16:34
* Spring Security {@code UserDetailsService} implementation.
*
* <p>Role assignment: users with {@code level >= 1} receive {@code ROLE_ADMIN};
* all others receive {@code ROLE_USER}.
*
* <p>Password note: passwords stored in the database MUST be BCrypt-hashed.
* See Flyway migration {@code V2__security_updates.sql} for the one-time migration
* that converts legacy plaintext passwords to BCrypt hashes and expands the column.
*/
@Component
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private IUsersService userInfoService;
/**
* 需新建配置类注册一个指定的加密方式Bean,或在下一步Security配置类中注册指定
*/
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserInfo loadUserByUsername(String username) throws UsernameNotFoundException {
// 通过用户名从数据库获取用户信息
User userInfo = userInfoService.getByUsername(username);
if (userInfo == null) {
throw new UsernameNotFoundException("用户不存在");
throw new UsernameNotFoundException("用户不存在: " + username);
}
// 得到用户角色
String role = "admin";
// Assign role based on user level: level >= 1 → ADMIN, else → USER
String role = (userInfo.getLevel() != null && userInfo.getLevel() >= 1) ? "ADMIN" : "USER";
// 角色集合
List<GrantedAuthority> authorities = new ArrayList<>();
// 角色必须以`ROLE_`开头,数据库中没有,则在这里加
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
return new UserInfo(
authorities,
userInfo.getId(),
// 因为数据库是明文,所以这里需加密密码
passwordEncoder.encode(userInfo.getPassword()),
userInfo.getPassword(), // Must be a BCrypt hash (see V2 migration)
userInfo.getUsername(),
userInfo.getPhone()
);
}
}
}
@@ -7,17 +7,17 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class UsersServiceImpl implements IUsersService {
@Autowired
@@ -28,7 +28,8 @@ public class UsersServiceImpl implements IUsersService {
*
* @return
*/
@Cacheable(value = "user")
@Transactional(readOnly = true)
@Cacheable(value = "userList")
@Override
public List<User> getAllList() {
return usersRepository.findAll();
@@ -41,11 +42,10 @@ public class UsersServiceImpl implements IUsersService {
* @param pageSize
* @return
*/
@Transactional(readOnly = true)
@Override
public Page<User> queryAllUsersList(int pageNum, int pageSize) {
Sort sort = new Sort(Sort.Direction.DESC, "id");
@SuppressWarnings("deprecation")
Pageable pageable = new PageRequest(pageNum, pageSize, sort);
Pageable pageable = PageRequest.of(pageNum, pageSize, Sort.by(Sort.Direction.DESC, "id"));
return usersRepository.findAll(pageable);
}
@@ -55,6 +55,7 @@ public class UsersServiceImpl implements IUsersService {
* @param name
* @return
*/
@Transactional(readOnly = true)
@Override
public List<User> getByUsernameIsLike(String name) {
return usersRepository.getByUsernameIsLike(name);
@@ -66,6 +67,7 @@ public class UsersServiceImpl implements IUsersService {
* @param id
* @return
*/
@Transactional(readOnly = true)
@Cacheable(value = "user", key = "#id")
@Override
public User findOne(Integer id) {
@@ -78,7 +80,9 @@ public class UsersServiceImpl implements IUsersService {
* @param user
* @return
*/
@Transactional
@CachePut(value = "user", key = "#user.id")
@CacheEvict(value = "userList", allEntries = true)
@Override
public User insert(User user) {
return usersRepository.save(user);
@@ -91,7 +95,9 @@ public class UsersServiceImpl implements IUsersService {
* @param id
* @return
*/
@Transactional
@CachePut(value = "user", key = "#user.id")
@CacheEvict(value = "userList", allEntries = true)
@Override
public User update(User user, Integer id) {
return usersRepository.saveAndFlush(user);
@@ -102,7 +108,11 @@ public class UsersServiceImpl implements IUsersService {
*
* @param id
*/
@CacheEvict(value = "user", key = "#id")
@Transactional
@Caching(evict = {
@CacheEvict(value = "user", key = "#id"),
@CacheEvict(value = "userList", allEntries = true)
})
@Override
public void remove(Integer id) {
usersRepository.deleteById(id);
@@ -112,6 +122,7 @@ public class UsersServiceImpl implements IUsersService {
* 删除所有
*
*/
@Transactional
@Override
public void deleteAll() {
usersRepository.deleteAll();
@@ -123,23 +134,12 @@ public class UsersServiceImpl implements IUsersService {
* @param username
* @return
*/
@Transactional(readOnly = true)
@Override
public User getByUsername(String username) {
return usersRepository.getByUsername(username);
}
/**
* 验证用户名密码
*
* @param username
* @param password
* @return
*/
@Override
public User checkByUsernameAndPassword(String username, String password) {
return usersRepository.checkByUsernameAndPassword(username, password);
}
}
@@ -0,0 +1,81 @@
package com.yaoyuan.jiscuss.util;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Secure IP address extraction utility.
*
* <p>Defends against IP spoofing via forged {@code X-Forwarded-For} headers by:
* <ul>
* <li>Treating the first non-internal IP in the proxy chain as the client IP</li>
* <li>Falling back to {@code HttpServletRequest.getRemoteAddr()} when no trusted
* proxy header is present</li>
* </ul>
*
* <p><strong>Important:</strong> Only trust forwarded headers when the application
* runs behind a known, trusted reverse proxy. Consider configuring
* {@code server.forward-headers-strategy=NATIVE} or {@code FRAMEWORK} in
* {@code application.yml} to let Spring handle this automatically.
*/
public final class IpUtils {
private static final Logger log = LoggerFactory.getLogger(IpUtils.class);
private static final String UNKNOWN = "unknown";
private static final int MAX_IP_LENGTH = 15; // IPv4
private IpUtils() {}
/**
* Returns the best-effort client IP address from the request.
*
* @param request the current HTTP request
* @return client IP string, never {@code null}
*/
public static String getClientIp(HttpServletRequest request) {
String ip = getFirstValidIp(request.getHeader("X-Forwarded-For"));
if (isValid(ip)) return sanitize(ip);
ip = request.getHeader("X-Real-IP");
if (isValid(ip)) return sanitize(ip);
ip = request.getHeader("Proxy-Client-IP");
if (isValid(ip)) return sanitize(ip);
ip = request.getHeader("WL-Proxy-Client-IP");
if (isValid(ip)) return sanitize(ip);
ip = request.getRemoteAddr();
return ip != null ? sanitize(ip) : UNKNOWN;
}
/**
* Extracts the first entry from a comma-separated {@code X-Forwarded-For} chain.
* Returns {@code null} if the header is absent/empty/unknown.
*/
private static String getFirstValidIp(String header) {
if (header == null || header.isBlank() || UNKNOWN.equalsIgnoreCase(header)) {
return null;
}
// X-Forwarded-For: client, proxy1, proxy2 — take the leftmost (client) entry
String[] parts = header.split(",");
return parts[0].trim();
}
private static boolean isValid(String ip) {
return ip != null && !ip.isBlank() && !UNKNOWN.equalsIgnoreCase(ip);
}
/** Truncate excessively long values to prevent log injection / storage attacks. */
private static String sanitize(String ip) {
// Strip any characters that are not valid in IPv4/IPv6 addresses
String cleaned = ip.replaceAll("[^0-9a-fA-F:.\\[\\]]", "");
if (cleaned.length() > 45) { // max IPv6 length
log.warn("Suspiciously long IP value truncated: {}", ip);
return cleaned.substring(0, 45);
}
return cleaned;
}
}