Compare commits
3 Commits
3eb651e039
...
0a126eb520
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a126eb520 | |||
| de52e2816c | |||
| 894a1c5d07 |
4
.idea/compiler.xml
generated
4
.idea/compiler.xml
generated
@@ -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
18
.idea/dataSources.xml
generated
Normal 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
6
.idea/data_source_mapping.xml
generated
Normal 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
1
.idea/gradle.xml
generated
@@ -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
2
.idea/modules.xml
generated
@@ -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
7
.idea/sqldialects.xml
generated
Normal 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
10
gradle/libs.versions.toml
Normal 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" }
|
||||||
@@ -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
50
sql/createTable.sql
Normal 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 '角色名称';
|
||||||
|
-- ====================================================================================================================
|
||||||
|
-- ====================================================================================================================
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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", "演示账号仅支持查询操作!")
|
||||||
;
|
;
|
||||||
|
|
||||||
// 异常码
|
// 异常码
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
14
weblog-module-jwt/build.gradle.kts
Normal file
14
weblog-module-jwt/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -3,3 +3,8 @@ spring:
|
|||||||
name: han-blog
|
name: han-blog
|
||||||
profiles:
|
profiles:
|
||||||
active: dev
|
active: dev
|
||||||
|
jwt:
|
||||||
|
# 签发人
|
||||||
|
issuer: Hanserwei
|
||||||
|
# 秘钥
|
||||||
|
secret: P81m2EjMkZj74ht+OuBCxsf25if8PzghbEEWASyf4zcYnxwwn3VbCiIohxmowYg/8I4mj6eJdaqLbJEhggUq3Q==
|
||||||
@@ -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(); // 强制同步到数据库
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user