Compare commits

...

3 Commits

Author SHA1 Message Date
0a126eb520 feat(security): implement JWT-based authentication and authorization
- Configured JWT token validation filter in security chain
- Added user role mapping with new t_user_role table and UserRole entity
- Implemented custom authentication entry point and access denied handler
- Updated UserDetailService to load user roles from database
- Added @PreAuthorize annotation support for method-level security
- Refactored build scripts to use java-library plugin and proper dependency scope
- Enhanced SQL schema with user role table and improved table comments
- Added global exception handler for AccessDeniedException
- Introduced ResponseCodeEnum constants for unauthorized and forbidden access
- Integrated TokenAuthenticationFilter into Spring Security filter chain
2025-11-29 15:19:35 +08:00
de52e2816c feat(jwt): implement JWT-based authentication system 2025-11-29 12:01:09 +08:00
894a1c5d07 feat(user): add user entity and repository with JPA integration 2025-11-29 09:38:24 +08:00
37 changed files with 1039 additions and 26 deletions

4
.idea/compiler.xml generated
View File

@@ -8,10 +8,12 @@
<processorPath useClasspath="false"> <processorPath useClasspath="false">
<entry name="$USER_HOME$/.gradle/caches/modules-2/files-2.1/org.projectlombok/lombok/1.18.42/8365263844ebb62398e0dc33057ba10ba472d3b8/lombok-1.18.42.jar" /> <entry name="$USER_HOME$/.gradle/caches/modules-2/files-2.1/org.projectlombok/lombok/1.18.42/8365263844ebb62398e0dc33057ba10ba472d3b8/lombok-1.18.42.jar" />
</processorPath> </processorPath>
<module name="weblog-springboot.weblog-module-jwt.main" />
<module name="weblog-springboot.weblog-web.test" /> <module name="weblog-springboot.weblog-web.test" />
<module name="weblog-springboot.weblog-module-admin.test" />
<module name="weblog-springboot.weblog-module-common.test" /> <module name="weblog-springboot.weblog-module-common.test" />
<module name="weblog-springboot.weblog-module-admin.main" /> <module name="weblog-springboot.weblog-module-admin.main" />
<module name="weblog-springboot.weblog-module-jwt.test" />
<module name="weblog-springboot.weblog-module-admin.test" />
<module name="weblog-springboot.weblog-module-common.main" /> <module name="weblog-springboot.weblog-module-common.main" />
<module name="weblog-springboot.weblog-web.main" /> <module name="weblog-springboot.weblog-web.main" />
</profile> </profile>

18
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="weblog@127.0.0.1" uuid="bb8330e4-9a89-4978-ad63-ad6402096c16">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://127.0.0.1:5432/weblog</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

6
.idea/data_source_mapping.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/sql/createTable.sql" value="bb8330e4-9a89-4978-ad63-ad6402096c16" />
</component>
</project>

1
.idea/gradle.xml generated
View File

@@ -10,6 +10,7 @@
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/weblog-module-admin" /> <option value="$PROJECT_DIR$/weblog-module-admin" />
<option value="$PROJECT_DIR$/weblog-module-common" /> <option value="$PROJECT_DIR$/weblog-module-common" />
<option value="$PROJECT_DIR$/weblog-module-jwt" />
<option value="$PROJECT_DIR$/weblog-web" /> <option value="$PROJECT_DIR$/weblog-web" />
</set> </set>
</option> </option>

2
.idea/modules.xml generated
View File

@@ -3,7 +3,9 @@
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/weblog-springboot.iml" filepath="$PROJECT_DIR$/weblog-springboot.iml" /> <module fileurl="file://$PROJECT_DIR$/weblog-springboot.iml" filepath="$PROJECT_DIR$/weblog-springboot.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/weblog-module-admin/weblog-springboot.weblog-module-admin.main.iml" filepath="$PROJECT_DIR$/.idea/modules/weblog-module-admin/weblog-springboot.weblog-module-admin.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/weblog-module-common/weblog-springboot.weblog-module-common.main.iml" filepath="$PROJECT_DIR$/.idea/modules/weblog-module-common/weblog-springboot.weblog-module-common.main.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/modules/weblog-module-common/weblog-springboot.weblog-module-common.main.iml" filepath="$PROJECT_DIR$/.idea/modules/weblog-module-common/weblog-springboot.weblog-module-common.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/weblog-module-jwt/weblog-springboot.weblog-module-jwt.main.iml" filepath="$PROJECT_DIR$/.idea/modules/weblog-module-jwt/weblog-springboot.weblog-module-jwt.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/weblog-web/weblog-springboot.weblog-web.main.iml" filepath="$PROJECT_DIR$/.idea/modules/weblog-web/weblog-springboot.weblog-web.main.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/modules/weblog-web/weblog-springboot.weblog-web.main.iml" filepath="$PROJECT_DIR$/.idea/modules/weblog-web/weblog-springboot.weblog-web.main.iml" />
</modules> </modules>
</component> </component>

7
.idea/sqldialects.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/sql/createTable.sql" dialect="PostgreSQL" />
<file url="PROJECT" dialect="PostgreSQL" />
</component>
</project>

10
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,10 @@
[versions]
# 定义版本号
jjwt = "0.13.0"
[libraries]
# 定义依赖包的别名 (bundles 是可选的libraries 是必须的)
# 对应 Maven 的 <groupId>io.jsonwebtoken</groupId>
jjwt-api = { group = "io.jsonwebtoken", name = "jjwt-api", version.ref = "jjwt" }
jjwt-impl = { group = "io.jsonwebtoken", name = "jjwt-impl", version.ref = "jjwt" }
jjwt-jackson = { group = "io.jsonwebtoken", name = "jjwt-jackson", version.ref = "jjwt" }

View File

@@ -3,3 +3,4 @@ rootProject.name = "weblog-springboot"
include("weblog-web") include("weblog-web")
include("weblog-module-admin") include("weblog-module-admin")
include("weblog-module-common") include("weblog-module-common")
include("weblog-module-jwt")

50
sql/createTable.sql Normal file
View File

@@ -0,0 +1,50 @@
-- ====================================================================================================================
-- ====================================================================================================================
-- 1. 创建一个函数,用于在数据更新时自动修改 update_time 字段
CREATE OR REPLACE FUNCTION set_update_time()
RETURNS TRIGGER AS
$$
BEGIN
NEW.update_time = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 2. 创建表(使用 BOOLEAN 替代 SMALLINT for is_deleted
CREATE TABLE t_user
(
id BIGSERIAL PRIMARY KEY,
username VARCHAR(60) NOT NULL UNIQUE,
password VARCHAR(60) NOT NULL,
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- WITH TIME ZONE 是更严谨的选择
update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- 使用 BOOLEAN 逻辑删除DEFAULT FALSE 对应 '0未删除'
is_deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 3. 创建触发器,在每次 UPDATE 操作前调用函数
CREATE TRIGGER set_t_user_update_time
BEFORE UPDATE
ON t_user
FOR EACH ROW
EXECUTE FUNCTION set_update_time();
-- 添加注释
COMMENT ON TABLE t_user IS '用户表(优化版)';
COMMENT ON COLUMN t_user.is_deleted IS '逻辑删除FALSE未删除 TRUE已删除';
-- ====================================================================================================================
-- ====================================================================================================================
-- ====================================================================================================================
-- ====================================================================================================================
CREATE TABLE t_user_role
(
id BIGSERIAL PRIMARY KEY,
username VARCHAR(60) NOT NULL,
role_name VARCHAR(60) NOT NULL, -- 重命名为 role_name 避免关键字冲突
create_time TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_username ON t_user_role (username);
COMMENT ON COLUMN t_user_role.role_name IS '角色名称';
-- ====================================================================================================================
-- ====================================================================================================================

View File

@@ -1,10 +1,14 @@
plugins { plugins {
java `java-library`
} }
dependencies { dependencies {
// Test api("org.springframework.boot:spring-boot-starter-security")
testImplementation("org.springframework.boot:spring-boot-starter-test")
implementation(project(":weblog-module-common")) implementation(project(":weblog-module-common"))
api(project(":weblog-module-jwt"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
} }

View File

@@ -0,0 +1,76 @@
package com.hanserwei.admin.config;
import com.hanserwei.jwt.config.JwtAuthenticationSecurityConfig;
import com.hanserwei.jwt.handler.RestAccessDeniedHandler;
import com.hanserwei.jwt.handler.RestAuthenticationEntryPoint;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig {
@Resource
private JwtAuthenticationSecurityConfig jwtAuthenticationSecurityConfig;
@Resource
private RestAccessDeniedHandler restAccessDeniedHandler;
@Resource
private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 1. 禁用 CSRF
.csrf(AbstractHttpConfigurer::disable)
// 2. 禁用表单登录
.formLogin(AbstractHttpConfigurer::disable)
// 3. 授权规则配置
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").authenticated() // 保护 /admin/**
.anyRequest().permitAll() // 其他放行
)
// 4. 会话管理:无状态
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 5. 自定义未登录/权限不足的响应
.exceptionHandling(exception -> exception
.authenticationEntryPoint(restAuthenticationEntryPoint)
.accessDeniedHandler(restAccessDeniedHandler)
)
// 6. 应用自定义配置 (核心变化)
.with(jwtAuthenticationSecurityConfig, Customizer.withDefaults());
return http.build();
}
/**
* 定义 AuthenticationProvider Bean。
* Spring Security 会自动将此 Provider 注入到 AuthenticationManager 中。
*/
@Bean
public AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
// 修正点:直接在构造函数中传入 userDetailsService
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService);
// 设置密码编码器 (PasswordEncoder 依然使用 setter 设置)
authProvider.setPasswordEncoder(passwordEncoder);
return authProvider;
}
}

View File

@@ -1,20 +1,21 @@
plugins { plugins {
java `java-library`
} }
dependencies { dependencies {
// guava
implementation("com.google.guava:guava:33.5.0-jre") implementation("com.google.guava:guava:33.5.0-jre")
// commons-lang3
implementation("org.apache.commons:commons-lang3:3.20.0") implementation("org.apache.commons:commons-lang3:3.20.0")
// test
testImplementation("org.springframework.boot:spring-boot-starter-test")
// jackson
implementation("com.fasterxml.jackson.core:jackson-databind") implementation("com.fasterxml.jackson.core:jackson-databind")
implementation("com.fasterxml.jackson.core:jackson-core") implementation("com.fasterxml.jackson.core:jackson-core")
// aop
implementation("org.springframework.boot:spring-boot-starter-aop") api("org.springframework.boot:spring-boot-starter-data-jpa")
// web api("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-web") api("org.springframework.boot:spring-boot-starter-security")
api("org.springframework.boot:spring-boot-starter-aop")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
} }

View File

@@ -0,0 +1,82 @@
package com.hanserwei.common.domain.dataobject;
import jakarta.persistence.*; // 使用 Jakarta Persistence API (JPA 3.0+)
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.io.Serial;
import java.io.Serializable;
import java.time.Instant; // 推荐用于 TIMESTAMP WITH TIME ZONE
/**
* 用户表t_user 对应实体)
*/
@Entity
@Setter
@Getter
@Builder
@Table(name = "t_user") // 对应数据库中的表名
public class User implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* ID (BIG SERIAL)
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
/**
* 用户名 (VARCHAR(60) NOT NULL UNIQUE)
*/
@Column(name = "username", length = 60, nullable = false, unique = true)
private String username;
/**
* 密码 (VARCHAR(60) NOT NULL)
*/
@Column(name = "password", length = 60, nullable = false)
private String password;
/**
* 创建时间 (TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW())
* 使用 Hibernate 的 @CreationTimestamp 确保创建时自动赋值
*/
@CreationTimestamp
@Column(name = "create_time", nullable = false, updatable = false)
private Instant createTime;
/**
* 最后一次更新时间 (TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW())
* 使用 Hibernate 的 @UpdateTimestamp 确保更新时自动赋值
* 注意:虽然数据库有触发器,但使用此注解可保持 ORM 层的同步性
*/
@UpdateTimestamp
@Column(name = "update_time", nullable = false)
private Instant updateTime;
/**
* 逻辑删除FALSE未删除 TRUE已删除 (BOOLEAN NOT NULL DEFAULT FALSE)
*/
@Builder.Default
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false; // 对应数据库默认值
public User() {
}
public User(Long id, String username, String password, Instant createTime, Instant updateTime, Boolean isDeleted) {
this.id = id;
this.username = username;
this.password = password;
this.createTime = createTime;
this.updateTime = updateTime;
this.isDeleted = isDeleted;
}
}

View File

@@ -0,0 +1,64 @@
package com.hanserwei.common.domain.dataobject;
import jakarta.persistence.*; // 使用 Jakarta Persistence API (JPA 3.0+)
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import java.io.Serial;
import java.io.Serializable;
import java.time.Instant;
/**
* 用户角色表t_user_role 对应实体)
*/
@Setter
@Getter
@Entity
@Table(name = "t_user_role",
indexes = {
@Index(name = "idx_username", columnList = "username") // 映射数据库中的 idx_username 索引
})
public class UserRole implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* ID (BIG SERIAL PRIMARY KEY)
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
/**
* 用户名 (VARCHAR(60) NOT NULL)
*/
@Column(name = "username", length = 60, nullable = false)
private String username;
/**
* 角色名称 (VARCHAR(60) NOT NULL)
*/
@Column(name = "role_name", length = 60, nullable = false)
private String roleName;
/**
* 创建时间 (TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP)
*/
@CreationTimestamp
@Column(name = "create_time", nullable = false, updatable = false)
private Instant createTime;
// --- 构造函数 (Constructor) ---
public UserRole() {
}
public UserRole(String username, String roleName) {
this.username = username;
this.roleName = roleName;
}
}

View File

@@ -0,0 +1,8 @@
package com.hanserwei.common.domain.repository;
import com.hanserwei.common.domain.dataobject.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
User getUsersByUsername(String username);
}

View File

@@ -0,0 +1,11 @@
package com.hanserwei.common.domain.repository;
import com.hanserwei.common.domain.dataobject.UserRole;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface UserRoleRepository extends JpaRepository<UserRole, Long> {
List<UserRole> queryAllByUsername(String username);
}

View File

@@ -13,6 +13,10 @@ public enum ResponseCodeEnum implements BaseExceptionInterface {
PARAM_NOT_VALID("10001", "参数错误"), PARAM_NOT_VALID("10001", "参数错误"),
// ----------- 业务异常状态码 ----------- // ----------- 业务异常状态码 -----------
LOGIN_FAIL("20000", "登录失败"),
USERNAME_OR_PWD_ERROR("20001", "用户名或密码错误"),
UNAUTHORIZED("20002", "无访问权限,请先登录!"),
FORBIDDEN("20004", "演示账号仅支持查询操作!")
; ;
// 异常码 // 异常码

View File

@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.security.access.AccessDeniedException;
import java.util.Optional; import java.util.Optional;
@ControllerAdvice @ControllerAdvice
@@ -76,4 +77,11 @@ public class GlobalExceptionHandler {
return Response.fail(errorCode, errorMessage); return Response.fail(errorCode, errorMessage);
} }
@ExceptionHandler({ AccessDeniedException.class })
public void throwAccessDeniedException(AccessDeniedException e) throws AccessDeniedException {
// 捕获到鉴权失败异常,主动抛出,交给 RestAccessDeniedHandler 去处理
log.info("============= 捕获到 AccessDeniedException");
throw e;
}
} }

View File

@@ -0,0 +1,14 @@
plugins {
`java-library`
}
dependencies {
api("org.springframework.boot:spring-boot-starter-security")
implementation(project(":weblog-module-common"))
implementation("org.apache.commons:commons-lang3:3.20.0")
// jwt
api(libs.jjwt.api)
runtimeOnly(libs.jjwt.impl)
runtimeOnly(libs.jjwt.jackson)
}

View File

@@ -0,0 +1,46 @@
package com.hanserwei.jwt.config;
import com.hanserwei.jwt.filter.JwtAuthenticationFilter;
import com.hanserwei.jwt.filter.TokenAuthenticationFilter;
import com.hanserwei.jwt.handler.RestAuthenticationFailureHandler;
import com.hanserwei.jwt.handler.RestAuthenticationSuccessHandler;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class JwtAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Resource
private RestAuthenticationSuccessHandler restAuthenticationSuccessHandler;
@Resource
private RestAuthenticationFailureHandler restAuthenticationFailureHandler;
@Resource
private TokenAuthenticationFilter tokenAuthenticationFilter;
@Override
public void configure(HttpSecurity httpSecurity) {
// 1. 实例化自定义过滤器
JwtAuthenticationFilter filter = new JwtAuthenticationFilter();
// 2. 这里的 AuthenticationManager 是从外部传入的 (httpSecurity sharedObject)
filter.setAuthenticationManager(httpSecurity.getSharedObject(AuthenticationManager.class));
// 3. 设置成功/失败处理器
filter.setAuthenticationSuccessHandler(restAuthenticationSuccessHandler);
filter.setAuthenticationFailureHandler(restAuthenticationFailureHandler);
// 4. 将过滤器添加到 UsernamePasswordAuthenticationFilter 之前
// (JWT 校验通常在用户名密码校验之前或者用来替换它addFilterBefore 是最稳妥的)
httpSecurity.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
// 5. 在链路中加入 Token 校验过滤器,确保携带 Token 的请求被识别为已登录
httpSecurity.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}

View File

@@ -0,0 +1,20 @@
package com.hanserwei.jwt.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordEncoderConfig {
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
System.out.println(encoder.encode("hanserwei"));
}
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt 是一种安全且适合密码存储的哈希算法,它在进行哈希时会自动加入“盐”,增加密码的安全性。
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,14 @@
package com.hanserwei.jwt.exception;
import org.springframework.security.core.AuthenticationException;
public class UsernameOrPasswordNullException extends AuthenticationException {
public UsernameOrPasswordNullException(String msg, Throwable cause) {
super(msg, cause);
}
public UsernameOrPasswordNullException(String msg) {
super(msg);
}
}

View File

@@ -0,0 +1,47 @@
package com.hanserwei.jwt.filter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hanserwei.jwt.exception.UsernameOrPasswordNullException;
import io.micrometer.common.util.StringUtils;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.RegexRequestMatcher;
import java.io.IOException;
import java.util.Objects;
public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public JwtAuthenticationFilter() {
super(new RegexRequestMatcher("/login", HttpMethod.POST.name()));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
ObjectMapper mapper = new ObjectMapper();
// 解析提交的JSON数据
JsonNode jsonNode = mapper.readTree(request.getInputStream());
JsonNode usernameNode = jsonNode.get("username");
JsonNode passwordNode = jsonNode.get("password");
// 判断用户名、密码是否为空
if (Objects.isNull(usernameNode) || Objects.isNull(passwordNode)
|| StringUtils.isBlank(usernameNode.textValue()) || StringUtils.isBlank(passwordNode.textValue())) {
throw new UsernameOrPasswordNullException("用户名或密码不能为空");
}
String username = usernameNode.textValue();
String password = passwordNode.textValue();
// 将用户名、密码封装到 Token 中
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(username, password);
return getAuthenticationManager().authenticate(usernamePasswordAuthenticationToken);
}
}

View File

@@ -0,0 +1,101 @@
package com.hanserwei.jwt.filter;
import com.hanserwei.jwt.utils.JwtTokenHelper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Resource;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
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.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Objects;
@Slf4j
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
@Resource
private JwtTokenHelper jwtTokenHelper;
@Resource
private UserDetailsService userDetailsService;
@Resource
private AuthenticationEntryPoint authenticationEntryPoint;
@Override
protected void doFilterInternal(HttpServletRequest request,
@Nonnull HttpServletResponse response,
@Nonnull FilterChain filterChain) throws ServletException, IOException {
// 1. 从请求头中获取 Authorization
String header = request.getHeader("Authorization");
// 2. 校验头格式 (必须以 Bearer 开头)
if (StringUtils.startsWith(header, "Bearer ")) {
String token = StringUtils.substring(header, 7);
log.info("JWT Token: {}", token);
if (StringUtils.isNotBlank(token)) {
try {
// 3. 解析 Token (核心步骤)
// 注意JwtTokenHelper.parseToken 方法内部已经处理了 JWT 格式校验和过期校验,
// 并将 JJWT 异常转换为了 Spring Security 的 AuthenticationException 抛出。
Jws<Claims> claimsJws = jwtTokenHelper.parseToken(token);
// 4. 获取用户名
// JJWT 0.12+ 建议使用 getPayload() 替代 getBody()
// 在 Helper 中生成 Token 时使用的是 .subject(username),所以这里取 Subject
String username = claimsJws.getPayload().getSubject();
// 5. 组装认证信息 (如果当前上下文没有认证信息)
if (StringUtils.isNotBlank(username) && Objects.isNull(SecurityContextHolder.getContext().getAuthentication())) {
// 查询数据库获取用户完整信息 (包含权限)
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 构建 Spring Security 的认证 Token
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 6. 将认证信息存入 SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (AuthenticationException e) {
// 7. 异常处理
// 捕获 JwtTokenHelper 抛出的 BadCredentialsException 或 CredentialsExpiredException
// 如果 Token 存在但是无效/过期,直接交给 EntryPoint 处理响应 (通常返回 401)
// 并 return 结束当前过滤器,不再往下执行
authenticationEntryPoint.commence(request, response, e);
return;
} catch (Exception e) {
// 处理其他未预料到的异常
log.error("JWT处理过程中发生未知错误", e);
authenticationEntryPoint.commence(request, response, new AuthenticationException("系统内部认证错误") {
});
return;
}
}
}
// 8. 放行请求 (如果没有 Token 或者 Token 校验通过,继续执行下一个过滤器)
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,26 @@
package com.hanserwei.jwt.handler;
import com.hanserwei.common.enums.ResponseCodeEnum;
import com.hanserwei.common.utils.Response;
import com.hanserwei.jwt.utils.ResultUtil;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Slf4j
@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
log.warn("登录成功访问收保护的资源,但是权限不够: ", accessDeniedException);
// 预留,后面引入多角色时会用到
ResultUtil.fail(response, Response.fail(ResponseCodeEnum.FORBIDDEN));
}
}

View File

@@ -0,0 +1,32 @@
package com.hanserwei.jwt.handler;
import com.hanserwei.common.enums.ResponseCodeEnum;
import com.hanserwei.common.utils.Response;
import com.hanserwei.jwt.utils.ResultUtil;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Slf4j
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.warn("用户未登录访问受保护的资源: ", authException);
if (authException instanceof InsufficientAuthenticationException) {
ResultUtil.fail(response, HttpStatus.UNAUTHORIZED.value(), Response.fail(ResponseCodeEnum.UNAUTHORIZED));
return;
}
ResultUtil.fail(response, HttpStatus.UNAUTHORIZED.value(), Response.fail(authException.getMessage()));
}
}

View File

@@ -0,0 +1,37 @@
package com.hanserwei.jwt.handler;
import com.hanserwei.common.enums.ResponseCodeEnum;
import com.hanserwei.common.utils.Response;
import com.hanserwei.jwt.exception.UsernameOrPasswordNullException;
import com.hanserwei.jwt.utils.ResultUtil;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Slf4j
@Component
public class RestAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
log.warn("AuthenticationException: ", exception);
if (exception instanceof UsernameOrPasswordNullException) {
// 用户名或密码为空
ResultUtil.fail(response, Response.fail(exception.getMessage()));
return;
} else if (exception instanceof BadCredentialsException) {
// 用户名或密码错误
ResultUtil.fail(response, Response.fail(ResponseCodeEnum.USERNAME_OR_PWD_ERROR));
return;
}
// 登录失败
ResultUtil.fail(response, Response.fail(ResponseCodeEnum.LOGIN_FAIL));
}
}

View File

@@ -0,0 +1,40 @@
package com.hanserwei.jwt.handler;
import com.hanserwei.common.utils.Response;
import com.hanserwei.jwt.model.LoginRspVO;
import com.hanserwei.jwt.utils.JwtTokenHelper;
import com.hanserwei.jwt.utils.ResultUtil;
import jakarta.annotation.Resource;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Slf4j
@Component
public class RestAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Resource
private JwtTokenHelper jwtTokenHelper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
// 从 authentication 对象中获取用户的 UserDetails 实例,这里是获取用户的用户名
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// 通过用户名生成 Token
String username = userDetails.getUsername();
String token = jwtTokenHelper.generateToken(username);
// 返回 Token
LoginRspVO loginRspVO = LoginRspVO.builder().token(token).build();
ResultUtil.ok(response, Response.success(loginRspVO));
}
}

View File

@@ -0,0 +1,19 @@
package com.hanserwei.jwt.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class LoginRspVO {
/**
* Token 值
*/
private String token;
}

View File

@@ -0,0 +1,52 @@
package com.hanserwei.jwt.service;
import com.hanserwei.common.domain.dataobject.User;
import com.hanserwei.common.domain.dataobject.UserRole;
import com.hanserwei.common.domain.repository.UserRepository;
import com.hanserwei.common.domain.repository.UserRoleRepository;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.util.List;
import java.util.Objects;
@Service
@Slf4j
public class UserDetailServiceImpl implements UserDetailsService {
@Resource
private UserRepository userRepository;
@Resource
private UserRoleRepository userRoleRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 从数据库查询用户信息
User user = userRepository.getUsersByUsername(username);
// 判断用户是否存在
if (Objects.isNull(user)) {
throw new UsernameNotFoundException("用户不存在");
}
List<UserRole> userRoles = userRoleRepository.queryAllByUsername(username);
// 转换为数组
String[] roleArr = new String[0];
if (!CollectionUtils.isEmpty(userRoles)) {
roleArr = userRoles.stream()
.map(UserRole::getRoleName)
.toArray(String[]::new);
}
// authorities 用于指定角色,这里写死为 ADMIN 管理员
return org.springframework.security.core.userdetails.User.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(roleArr)
.build();
}
}

View File

@@ -0,0 +1,110 @@
package com.hanserwei.jwt.utils;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
@Component
public class JwtTokenHelper implements InitializingBean {
/**
* 签发人
*/
@Value("${jwt.issuer}")
private String issuer;
/**
* 秘钥 (JJWT 0.12+ 强制要求使用 SecretKey 接口,且长度必须符合算法安全标准)
*/
private SecretKey key;
/**
* JWT 解析器 (线程安全,建议复用)
*/
private JwtParser jwtParser;
/**
* 工具方法:生成一个符合 HS512 标准的安全 Base64 秘钥
* 用于生成后填入 application.yml
*/
public static String generateBase64Key() {
SecretKey secretKey = Jwts.SIG.HS512.key().build();
return Encoders.BASE64.encode(secretKey.getEncoded()); // Fixed line
}
public static void main(String[] args) {
String key = generateBase64Key();
System.out.println("请将此 Key 复制到配置文件中 (HS512需要长Key): " + key);
}
/**
* 设置 Base64 秘钥
* 官方建议HS512 算法至少需要 512 bit (64字节) 的秘钥长度,否则会抛出 WeakKeyException
*/
@Value("${jwt.secret}")
public void setBase64Key(String base64Key) {
// 使用 JJWT 提供的 Decoders 工具,或者使用 java.util.Base64 均可
byte[] keyBytes = Decoders.BASE64.decode(base64Key);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/**
* 初始化 JwtParser
*/
@Override
public void afterPropertiesSet() {
// 0.13.0 标准写法:
// 1. 使用 parser() 而不是 parserBuilder()
// 2. 使用 verifyWith() 设置验签秘钥
jwtParser = Jwts.parser()
.requireIssuer(issuer)
.verifyWith(key)
// 允许的时钟偏差 (防止服务器时间不一致导致验证失败),默认单位秒
.clockSkewSeconds(10)
.build();
}
/**
* 生成 Token
*/
public String generateToken(String username) {
Instant now = Instant.now();
Instant expireTime = now.plus(1, ChronoUnit.HOURS);
return Jwts.builder()
.header().add("type", "JWT").and() // 推荐添加 header
.subject(username)
.issuer(issuer)
.issuedAt(Date.from(now))
.expiration(Date.from(expireTime))
// 0.13.0 标准:使用 Jwts.SIG 中的常量指定算法
.signWith(key, Jwts.SIG.HS512)
.compact();
}
/**
* 解析 Token
*/
public Jws<Claims> parseToken(String token) {
try {
// 0.13.0 标准:解析已签名的 JWS 使用 parseSignedClaims
return jwtParser.parseSignedClaims(token);
} catch (MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
// 注意SignatureException 现在属于 io.jsonwebtoken.security 包
throw new BadCredentialsException("Token 不可用或签名无效", e);
} catch (ExpiredJwtException e) {
throw new CredentialsExpiredException("Token 已失效", e);
}
}
}

View File

@@ -0,0 +1,67 @@
package com.hanserwei.jwt.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hanserwei.common.utils.Response;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import java.io.IOException;
import java.io.PrintWriter;
public class ResultUtil {
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 成功响应
*
* @param response HttpServletResponse对象
* @param result 响应数据
* @throws IOException IO异常
*/
public static void ok(HttpServletResponse response, Response<?> result) throws IOException {
writeResponse(response, HttpStatus.OK.value(), result);
}
/**
* 失败响应
*
* @param response HttpServletResponse对象
* @param result 响应数据
* @throws IOException IO异常
*/
public static void fail(HttpServletResponse response, Response<?> result) throws IOException {
writeResponse(response, HttpStatus.OK.value(), result);
}
/**
* 失败响应
*
* @param response HttpServletResponse对象
* @param status HTTP状态码
* @param result 响应数据
* @throws IOException IO异常
*/
public static void fail(HttpServletResponse response, int status, Response<?> result) throws IOException {
writeResponse(response, status, result);
}
/**
* 写入响应数据
*
* @param response HttpServletResponse对象
* @param status HTTP状态码
* @param result 响应数据
* @throws IOException IO异常
*/
private static void writeResponse(HttpServletResponse response, int status, Response<?> result) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setStatus(status);
response.setContentType("application/json");
try (PrintWriter writer = response.getWriter()) {
writer.write(objectMapper.writeValueAsString(result));
writer.flush();
}
}
}

View File

@@ -9,7 +9,6 @@ dependencies {
// Spring Boot Web示例 // Spring Boot Web示例
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
// Test // Test
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")

View File

@@ -2,10 +2,12 @@ package com.hanserwei.web;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan; import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication @SpringBootApplication(scanBasePackages = "com.hanserwei")
@ComponentScan({"com.hanserwei.*"}) @EnableJpaRepositories(basePackages = "com.hanserwei.common.domain.repository")
@EntityScan(basePackages = "com.hanserwei.common.domain.dataobject")
public class WeblogWebApplication { public class WeblogWebApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(WeblogWebApplication.class, args); SpringApplication.run(WeblogWebApplication.class, args);

View File

@@ -1,9 +1,11 @@
package com.hanserwei.web.controller; package com.hanserwei.web.controller;
import com.hanserwei.common.aspect.ApiOperationLog; import com.hanserwei.common.aspect.ApiOperationLog;
import com.hanserwei.common.utils.Response;
import com.hanserwei.web.model.User; import com.hanserwei.web.model.User;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.BindingResult; import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -17,9 +19,9 @@ import java.util.stream.Collectors;
@Slf4j @Slf4j
public class TestController { public class TestController {
@PostMapping("/test") @PostMapping("/admin/test")
@ApiOperationLog(description = "测试接口") @ApiOperationLog(description = "测试接口")
public ResponseEntity<String> test(@RequestBody @Validated User user, BindingResult bindingResult) { public ResponseEntity<String>test(@RequestBody @Validated User user, BindingResult bindingResult) {
// 是否存在校验错误 // 是否存在校验错误
if (bindingResult.hasErrors()) { if (bindingResult.hasErrors()) {
// 获取校验不通过字段的提示信息 // 获取校验不通过字段的提示信息
@@ -35,4 +37,12 @@ public class TestController {
return ResponseEntity.ok("参数没有任何问题"); return ResponseEntity.ok("参数没有任何问题");
} }
@PostMapping("/admin/update")
@ApiOperationLog(description = "测试更新接口")
@PreAuthorize("hasRole('ROLE_ADMIN')")
public Response<?> testUpdate() {
log.info("更新成功...");
return Response.success();
}
} }

View File

@@ -3,3 +3,8 @@ spring:
name: han-blog name: han-blog
profiles: profiles:
active: dev active: dev
jwt:
# 签发人
issuer: Hanserwei
# 秘钥
secret: P81m2EjMkZj74ht+OuBCxsf25if8PzghbEEWASyf4zcYnxwwn3VbCiIohxmowYg/8I4mj6eJdaqLbJEhggUq3Q==

View File

@@ -1,13 +1,19 @@
package com.hanserwei.web; package com.hanserwei.web;
import com.hanserwei.common.domain.dataobject.User;
import com.hanserwei.common.domain.repository.UserRepository;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import lombok.extern.slf4j.Slf4j;
@SpringBootTest
@Slf4j @Slf4j
@SpringBootTest
class WeblogWebApplicationTests { class WeblogWebApplicationTests {
@Resource
private UserRepository userRepository;
@Test @Test
void contextLoads() { void contextLoads() {
} }
@@ -23,4 +29,15 @@ class WeblogWebApplicationTests {
log.info("这是一行带有占位符日志,作者:{}", author); log.info("这是一行带有占位符日志,作者:{}", author);
} }
@Test
void insertTest() {
User user = User.builder()
.username("Hanserwei")
.password("123456")
.build();
// 使用jpa插入数据
User savedUser = userRepository.save(user); // 保存并获取返回的实体
userRepository.flush(); // 强制同步到数据库
}
} }