birc-generator 0.9.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROJECT.md +24 -5
- package/README.md +14 -12
- package/bin/birc.js +10 -4
- package/bin/cli-util.js +30 -2
- package/lib/create.js +150 -0
- package/lib/features.js +558 -0
- package/lib/make.js +658 -0
- package/lib/project.js +234 -0
- package/package.json +5 -3
- package/plopfile.js +62 -1334
- package/project-docs/PROJECT.md.hbs +14 -1
- package/templates/aop/OperationLogAspect.hbs +10 -3
- package/templates/auth/AuthController.hbs +73 -0
- package/templates/auth/AuthFlowTest.hbs +112 -0
- package/templates/auth/AuthSecurityCustomizer.hbs +38 -0
- package/templates/auth/AuthUser.hbs +54 -0
- package/templates/auth/AuthUserDAO.hbs +9 -0
- package/templates/auth/JpaUserDetailsService.hbs +47 -0
- package/templates/auth/JwtAuthenticationFilter.hbs +50 -0
- package/templates/auth/JwtProperties.hbs +35 -0
- package/templates/auth/JwtSecretEnvTest.hbs +68 -0
- package/templates/auth/JwtSecretEnvironmentPostProcessor.hbs +102 -0
- package/templates/auth/JwtService.hbs +60 -0
- package/templates/auth/LoginFailedException.hbs +27 -0
- package/templates/auth/LoginRequest.hbs +16 -0
- package/templates/auth/V1__create_auth_users_table.sql.hbs +13 -0
- package/templates/auth/application-yml-block.hbs +7 -0
- package/templates/auth/build-gradle-dep.hbs +4 -0
- package/templates/auth/spring.factories.hbs +1 -0
- package/templates/base/CorsTest.java.hbs +93 -0
- package/templates/base/README.md.hbs +6 -0
- package/templates/base/application-test.yml.hbs +3 -0
- package/templates/base/application.yml.hbs +12 -0
- package/templates/clockin/ClockInApiException.hbs +37 -0
- package/templates/clockin/ClockInApiResponse.hbs +14 -0
- package/templates/clockin/ClockInClient.hbs +251 -0
- package/templates/clockin/ClockInClientConfig.hbs +41 -0
- package/templates/clockin/ClockInController.hbs +77 -0
- package/templates/clockin/ClockInPage.hbs +11 -0
- package/templates/clockin/ClockInProperties.hbs +34 -0
- package/templates/clockin/ClockInRecord.hbs +20 -0
- package/templates/clockin/MemberImage.hbs +7 -0
- package/templates/clockin/OnDutyWeek.hbs +26 -0
- package/templates/clockin/UnclockedMember.hbs +7 -0
- package/templates/clockin/UserPermission.hbs +11 -0
- package/templates/clockin/application-yml-block.hbs +10 -0
- package/templates/docker/docker-compose.prod.yml.hbs +5 -0
- package/templates/docker/docker-compose.yml.hbs +7 -1
- package/templates/docker/env.example.hbs +15 -0
- package/templates/file-upload/FileExtensionUtils.hbs +45 -0
- package/templates/file-upload/FileExtensionUtilsTest.hbs +48 -0
- package/templates/file-upload/FileStorageServiceImpl.hbs +6 -0
- package/templates/multi-module/config/SecurityConfig.java.hbs +123 -8
- package/templates/multi-module/config/SecurityCustomizer.java.hbs +24 -0
- package/templates/openapi/ApiDocsAccessTest.hbs +60 -0
- package/templates/openapi/application-yml-block.hbs +8 -0
- package/test.md +7 -4
- package/versions.js +1 -0
|
@@ -58,16 +58,29 @@ DB → Entity → Mapper → XxxResponse → Result<T>
|
|
|
58
58
|
## 統一規範
|
|
59
59
|
|
|
60
60
|
- 例外丟 `ProjectException` 子類(例如 `NotFoundException`),由 `ExceptionHandleController` 轉成回應。Controller 不手動 try-catch 包錯誤訊息。
|
|
61
|
-
- 整組骨架用 `birc make`;單層用 `birc make:entity` / `make:model` / `make:mapper` / `make:service` / `make:controller` / `make:exception`。`make:entity` 只生 Entity 與 DAO;`--migration` / `--seed` 同 `make:model`(已有 Entity 就跳過,只補 SQL / Seeder)。`--dto` 與 `--mapper` 一併生 Mapper 與 DTO。`make:model` 可加 `--migration` / `--seed` / `--controller`。`make` 也可加 `--migration` / `--seed
|
|
61
|
+
- 整組骨架用 `birc make`;單層用 `birc make:entity` / `make:model` / `make:mapper` / `make:service` / `make:controller` / `make:exception`。`make:entity` 只生 Entity 與 DAO;`--migration` / `--seed` 同 `make:model`(已有 Entity 就跳過,只補 SQL / Seeder)。`--dto` 與 `--mapper` 一併生 Mapper 與 DTO。`make:model` 可加 `--migration` / `--seed` / `--controller`。`make` 也可加 `--migration` / `--seed`(已有 Java 就跳過,只補缺的檔與 SQL / Seeder)。`make:seeder BookSeeder` 生 Java Seeder。`--soft-delete` 用 Hibernate `@SoftDelete` 的 `deleted_at`,查詢自動排除已刪;不要自寫 `@SQLDelete`。預設是初稿,完整範例加 `--example` 或 `--fields`。`make:migration create_{table}_table` 讀對應 Entity 欄位寫進 SQL。`--seed` 生 Java Seeder,用 DAO 塞資料;`birc seed` 才執行。清空資料庫用 `birc db:wipe`。不要複製其他 Entity 的程式碼來改名。
|
|
62
62
|
- 欄位驗證用專案內 `validation/ValidGroup`。CRUD 用 `Create` / `Update`。`Delete` 給「刪除還要帶原因、確認碼、樂觀鎖 version」的 body。`Submit` 給草稿可少填、送審要全填(活動送審就是這組)。Controller 的 `@Validated` 與欄位 `groups` 對同一組。
|
|
63
63
|
- email / sso / oauth / validGroup 用 `birc add`,不要從別的專案抄設定。
|
|
64
64
|
- 格式化走 Spotless(`./gradlew spotlessApply`),不要另裝 Checkstyle。
|
|
65
65
|
- CORS 只改 `CORS_ALLOWED_ORIGIN_PATTERNS`(逗號分隔 Origin,不要尾斜線、不要 `*`)。`SecurityConfig` 已經用 `http.cors()` 接 `CorsConfigurationSource`。不要在 `WebMvcConfigurer` 加 `addCorsMappings`,Spring Security 會擋 preflight。空白 = 不開放跨來源。
|
|
66
|
+
- API 文件用 `API_DOCS_ENABLED` 一個開關控制,`SecurityConfig` 的 `API_DOCS_PATHS` 與 springdoc 的 `enabled` 都讀它。正式環境設 `false`。不要把 swagger 路徑手動塞回 `PUBLIC_PATHS`。
|
|
67
|
+
- 要改 `SecurityFilterChain`(放行路徑、加 filter)就寫一個實作 `SecurityCustomizer` 的 `@Component`,不要改 `SecurityConfig` 本體。放行路徑一定要走 `publicPaths()`:Spring Security 不允許在 `anyRequest()` 之後再加 matcher。
|
|
66
68
|
|
|
67
69
|
## 已啟用的功能模組
|
|
68
70
|
|
|
69
71
|
見 `.bircrc.json` 的 `features` 欄位。要加新的模組用 `birc add`。
|
|
70
72
|
|
|
73
|
+
## 登入(有裝 auth 才有)
|
|
74
|
+
|
|
75
|
+
`POST /api/auth/login` 收 `{"account":..,"password":..}`,成功時 JWT 在 **`X-Auth-Token` 回應標頭**,
|
|
76
|
+
不在 body。之後的請求用 `Authorization: Bearer <token>` 帶。`GET /api/auth/me` 回目前使用者與權限代碼。
|
|
77
|
+
|
|
78
|
+
- 帳號在 `auth_users` 表,`authorities` 是逗號分隔的權限代碼,跟 `@RequirePermission` 比對的是同一組字串,不加 `ROLE_` 前綴。
|
|
79
|
+
- 密碼存 `PasswordEncoder` 編碼後的字串(預設 bcrypt,前綴 `{bcrypt}`),不要放明碼。
|
|
80
|
+
- `JWT_SECRET` 放 `.env`,不要寫進 yml。沒設時啟動會看 `.env`,還是空的就產生一把寫進去;已有值不改。
|
|
81
|
+
- 沒有 refresh token,過期就重新登入。要加請自己做,不要改 `JwtService` 的驗章邏輯來延長。
|
|
82
|
+
- 未登入回 401、權限不足回 403。不要把兩者合併成同一種回應。
|
|
83
|
+
|
|
71
84
|
## SSO / Sentry
|
|
72
85
|
|
|
73
86
|
在 `application.yml` 設定:
|
|
@@ -24,6 +24,11 @@ public class OperationLogAspect {
|
|
|
24
24
|
@Value("${operation-log.trust-forwarded-user:false}")
|
|
25
25
|
private boolean trustForwardedUser;
|
|
26
26
|
|
|
27
|
+
// X-Forwarded-For 同樣可偽造。採信它等於讓呼叫端自己決定稽核日誌上的 IP,
|
|
28
|
+
// 事後要靠 log 追人就追錯對象。只有反向代理會覆寫此標頭時才打開。
|
|
29
|
+
@Value("${operation-log.trust-forwarded-for:false}")
|
|
30
|
+
private boolean trustForwardedFor;
|
|
31
|
+
|
|
27
32
|
@Pointcut("@annotation({{basePackage}}.annotation.OperationLog)")
|
|
28
33
|
public void operationLogPointcut() {
|
|
29
34
|
}
|
|
@@ -86,9 +91,11 @@ public class OperationLogAspect {
|
|
|
86
91
|
if (attributes == null) {
|
|
87
92
|
return "unknown";
|
|
88
93
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
if (trustForwardedFor) {
|
|
95
|
+
String forwarded = attributes.getRequest().getHeader("X-Forwarded-For");
|
|
96
|
+
if (forwarded != null && !forwarded.isBlank() && !"unknown".equalsIgnoreCase(forwarded)) {
|
|
97
|
+
return forwarded.split(",")[0].trim();
|
|
98
|
+
}
|
|
92
99
|
}
|
|
93
100
|
return attributes.getRequest().getRemoteAddr();
|
|
94
101
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package {{basePackage}}.controller;
|
|
2
|
+
|
|
3
|
+
import jakarta.validation.Valid;
|
|
4
|
+
import java.util.List;
|
|
5
|
+
import lombok.RequiredArgsConstructor;
|
|
6
|
+
import org.springframework.http.ResponseEntity;
|
|
7
|
+
import org.springframework.security.authentication.AuthenticationManager;
|
|
8
|
+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
9
|
+
import org.springframework.security.core.Authentication;
|
|
10
|
+
import org.springframework.security.core.GrantedAuthority;
|
|
11
|
+
import org.springframework.security.core.AuthenticationException;
|
|
12
|
+
import org.springframework.web.bind.annotation.GetMapping;
|
|
13
|
+
import org.springframework.web.bind.annotation.PostMapping;
|
|
14
|
+
import org.springframework.web.bind.annotation.RequestBody;
|
|
15
|
+
import org.springframework.web.bind.annotation.RequestMapping;
|
|
16
|
+
import org.springframework.web.bind.annotation.RestController;
|
|
17
|
+
import {{basePackage}}.dto.LoginRequest;
|
|
18
|
+
import {{basePackage}}.exception.LoginFailedException;
|
|
19
|
+
import {{basePackage}}.security.JwtService;
|
|
20
|
+
import {{basePackage}}.web.Result;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 登入。成功時 JWT 放在 X-Auth-Token 回應標頭,之後請求用 {@code Authorization: Bearer <token>}
|
|
24
|
+
* 帶回來。
|
|
25
|
+
*
|
|
26
|
+
* <p>
|
|
27
|
+
* body 只回帳號與權限代碼,不回 token——標頭與 body 各放一份會讓前端不知道 該信哪個,也容易被順手記進 log。
|
|
28
|
+
*/
|
|
29
|
+
@RestController
|
|
30
|
+
@RequestMapping("/api/auth")
|
|
31
|
+
@RequiredArgsConstructor
|
|
32
|
+
public class AuthController {
|
|
33
|
+
|
|
34
|
+
public static final String TOKEN_HEADER = "X-Auth-Token";
|
|
35
|
+
|
|
36
|
+
private final AuthenticationManager authenticationManager;
|
|
37
|
+
private final JwtService jwtService;
|
|
38
|
+
|
|
39
|
+
@PostMapping("/login")
|
|
40
|
+
public ResponseEntity<Result<LoginResponse>> login(@Valid @RequestBody LoginRequest request) {
|
|
41
|
+
Authentication authentication;
|
|
42
|
+
try {
|
|
43
|
+
authentication = authenticationManager.authenticate(
|
|
44
|
+
new UsernamePasswordAuthenticationToken(request.getAccount(), request.getPassword()));
|
|
45
|
+
} catch (AuthenticationException e) {
|
|
46
|
+
// 不要區分「帳號不存在」與「密碼錯誤」,那等於提供帳號列舉的管道。
|
|
47
|
+
throw new LoginFailedException();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
List<String> authorities = authentication.getAuthorities().stream()
|
|
51
|
+
.map(GrantedAuthority::getAuthority)
|
|
52
|
+
.toList();
|
|
53
|
+
String token = jwtService.issue(authentication.getName(), authorities);
|
|
54
|
+
|
|
55
|
+
return ResponseEntity.ok()
|
|
56
|
+
.header(TOKEN_HEADER, token)
|
|
57
|
+
.body(Result.success(new LoginResponse(authentication.getName(), authorities)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 目前登入者是誰、有哪些權限。前端拿它決定要不要顯示某個功能, 不要先打功能端點再等 403。
|
|
62
|
+
*/
|
|
63
|
+
@GetMapping("/me")
|
|
64
|
+
public Result<LoginResponse> me(Authentication authentication) {
|
|
65
|
+
List<String> authorities = authentication.getAuthorities().stream()
|
|
66
|
+
.map(GrantedAuthority::getAuthority)
|
|
67
|
+
.toList();
|
|
68
|
+
return Result.success(new LoginResponse(authentication.getName(), authorities));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public record LoginResponse(String account, List<String> authorities) {
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
package {{basePackage}};
|
|
2
|
+
|
|
3
|
+
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
4
|
+
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
5
|
+
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
6
|
+
|
|
7
|
+
import java.net.URI;
|
|
8
|
+
import java.net.http.HttpClient;
|
|
9
|
+
import java.net.http.HttpRequest;
|
|
10
|
+
import java.net.http.HttpResponse;
|
|
11
|
+
import org.junit.jupiter.api.BeforeEach;
|
|
12
|
+
import org.junit.jupiter.api.Test;
|
|
13
|
+
import org.springframework.beans.factory.annotation.Autowired;
|
|
14
|
+
import org.springframework.boot.test.context.SpringBootTest;
|
|
15
|
+
import org.springframework.boot.test.web.server.LocalServerPort;
|
|
16
|
+
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
17
|
+
import {{daoPackage}}.AuthUserDAO;
|
|
18
|
+
import {{entityPackage}}.AuthUser;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 登入流程只有整條走完才有意義:發得出 token、帶著它能過、沒帶會被擋。
|
|
22
|
+
* 任何一段斷掉,其它兩段各自「看起來正常」也沒有用。
|
|
23
|
+
*/
|
|
24
|
+
@SpringBootTest(
|
|
25
|
+
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
|
26
|
+
// 測試 profile 沒有跑 Flyway(那些 SQL 是 MySQL 方言),改讓 Hibernate 依 Entity 建表。
|
|
27
|
+
properties = "spring.jpa.hibernate.ddl-auto=create-drop")
|
|
28
|
+
class AuthFlowTest {
|
|
29
|
+
|
|
30
|
+
@LocalServerPort
|
|
31
|
+
private int port;
|
|
32
|
+
|
|
33
|
+
@Autowired
|
|
34
|
+
private AuthUserDAO authUserDAO;
|
|
35
|
+
|
|
36
|
+
@Autowired
|
|
37
|
+
private PasswordEncoder passwordEncoder;
|
|
38
|
+
|
|
39
|
+
@BeforeEach
|
|
40
|
+
void createUser() {
|
|
41
|
+
authUserDAO.findByAccount("tester").orElseGet(() -> {
|
|
42
|
+
AuthUser user = new AuthUser();
|
|
43
|
+
user.setAccount("tester");
|
|
44
|
+
user.setPassword(passwordEncoder.encode("secret"));
|
|
45
|
+
user.setAuthorities("user:read");
|
|
46
|
+
return authUserDAO.save(user);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private HttpResponse<String> login(String account, String password) throws Exception {
|
|
51
|
+
HttpRequest request = HttpRequest.newBuilder()
|
|
52
|
+
.uri(URI.create("http://localhost:" + port + "/api/auth/login"))
|
|
53
|
+
.header("Content-Type", "application/json")
|
|
54
|
+
.POST(HttpRequest.BodyPublishers.ofString(
|
|
55
|
+
"{\"account\":\"" + account + "\",\"password\":\"" + password + "\"}"))
|
|
56
|
+
.build();
|
|
57
|
+
return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private HttpResponse<String> callMe(String token) throws Exception {
|
|
61
|
+
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
|
62
|
+
.uri(URI.create("http://localhost:" + port + "/api/auth/me"))
|
|
63
|
+
.GET();
|
|
64
|
+
if (token != null) {
|
|
65
|
+
builder.header("Authorization", "Bearer " + token);
|
|
66
|
+
}
|
|
67
|
+
return HttpClient.newHttpClient().send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@Test
|
|
71
|
+
void loginReturnsTokenInHeader() throws Exception {
|
|
72
|
+
HttpResponse<String> response = login("tester", "secret");
|
|
73
|
+
|
|
74
|
+
assertEquals(200, response.statusCode());
|
|
75
|
+
String token = response.headers().firstValue("X-Auth-Token").orElse(null);
|
|
76
|
+
assertNotNull(token, "登入成功要在 X-Auth-Token 標頭帶 JWT");
|
|
77
|
+
assertTrue(token.split("\\.").length == 3, "X-Auth-Token 要是 JWT");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@Test
|
|
81
|
+
void tokenFromLoginPassesAuthenticationAndCarriesAuthorities() throws Exception {
|
|
82
|
+
// 防住:發得出 token 但 filter 沒掛進 chain,帶著它一樣進不來。
|
|
83
|
+
String token = login("tester", "secret").headers().firstValue("X-Auth-Token").orElseThrow();
|
|
84
|
+
|
|
85
|
+
HttpResponse<String> response = callMe(token);
|
|
86
|
+
|
|
87
|
+
assertEquals(200, response.statusCode());
|
|
88
|
+
assertTrue(response.body().contains("tester"));
|
|
89
|
+
// 權限代碼要原樣傳到 SecurityContext,@RequirePermission 比對的就是這組字串。
|
|
90
|
+
assertTrue(response.body().contains("user:read"));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@Test
|
|
94
|
+
void requestWithoutTokenIsRejected() throws Exception {
|
|
95
|
+
// 401 而不是 403:客戶端要能分出「請先登入」與「你沒這個權限」。
|
|
96
|
+
assertEquals(401, callMe(null).statusCode());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@Test
|
|
100
|
+
void forgedTokenIsRejected() throws Exception {
|
|
101
|
+
// 防住:簽章沒驗,任何人自己拼一段 JWT 就能當管理員。
|
|
102
|
+
String forged = "eyJhbGciOiJIUzI1NiJ9"
|
|
103
|
+
+ ".eyJzdWIiOiJhZG1pbiIsImF1dGhvcml0aWVzIjpbImFkbWluIl19.not-a-real-signature";
|
|
104
|
+
|
|
105
|
+
assertEquals(401, callMe(forged).statusCode());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test
|
|
109
|
+
void wrongPasswordIsRejected() throws Exception {
|
|
110
|
+
assertEquals(401, login("tester", "wrong").statusCode());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
package {{basePackage}}.config;
|
|
2
|
+
|
|
3
|
+
import java.util.List;
|
|
4
|
+
import lombok.RequiredArgsConstructor;
|
|
5
|
+
import org.springframework.http.HttpStatus;
|
|
6
|
+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|
7
|
+
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
|
8
|
+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
|
9
|
+
import org.springframework.stereotype.Component;
|
|
10
|
+
import {{basePackage}}.security.JwtAuthenticationFilter;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 把 JWT filter 掛進 SecurityFilterChain,並放行登入端點。
|
|
14
|
+
*
|
|
15
|
+
* <p>這是 birc-generator 唯一會碰 SecurityFilterChain 的地方,而且是透過
|
|
16
|
+
* {@link SecurityCustomizer} 這個縫,沒有改寫 SecurityConfig。要再加別的
|
|
17
|
+
* 放行路徑或 filter,照這個檔案再寫一個 Component 就好。
|
|
18
|
+
*/
|
|
19
|
+
@Component
|
|
20
|
+
@RequiredArgsConstructor
|
|
21
|
+
public class AuthSecurityCustomizer implements SecurityCustomizer {
|
|
22
|
+
|
|
23
|
+
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
|
24
|
+
|
|
25
|
+
@Override
|
|
26
|
+
public List<String> publicPaths() {
|
|
27
|
+
return List.of("/api/auth/login");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Override
|
|
31
|
+
public void customize(HttpSecurity http) throws Exception {
|
|
32
|
+
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
|
33
|
+
// 沒配 entry point 時 Spring Security 對未登入回 403,客戶端會分不出
|
|
34
|
+
// 「請先登入」與「你沒這個權限」。有了登入端點就該回 401。
|
|
35
|
+
.exceptionHandling(handling ->
|
|
36
|
+
handling.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
package {{entityPackage}};
|
|
2
|
+
|
|
3
|
+
import jakarta.persistence.Column;
|
|
4
|
+
import jakarta.persistence.Entity;
|
|
5
|
+
import jakarta.persistence.GeneratedValue;
|
|
6
|
+
import jakarta.persistence.GenerationType;
|
|
7
|
+
import jakarta.persistence.Id;
|
|
8
|
+
import jakarta.persistence.Table;
|
|
9
|
+
import java.time.LocalDateTime;
|
|
10
|
+
import lombok.Getter;
|
|
11
|
+
import lombok.Setter;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 登入用的使用者。
|
|
15
|
+
*
|
|
16
|
+
* <p>
|
|
17
|
+
* authorities 是逗號分隔的權限代碼(例如 {@code user:read,user:write}),跟
|
|
18
|
+
* {@code @RequirePermission} 比對的是同一組字串。一開始用一個欄位就夠;之後要做 角色與權限的多對多,再開新表、把這個欄位換掉。
|
|
19
|
+
*
|
|
20
|
+
* <p>
|
|
21
|
+
* password 存的是 {@code PasswordEncoder} 編碼後的字串(預設 bcrypt,前綴
|
|
22
|
+
* {@code {bcrypt}}),不要放明碼。
|
|
23
|
+
*/
|
|
24
|
+
@Entity
|
|
25
|
+
@Getter
|
|
26
|
+
@Setter
|
|
27
|
+
@Table(name = "auth_users")
|
|
28
|
+
public class AuthUser {
|
|
29
|
+
|
|
30
|
+
@Id
|
|
31
|
+
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
32
|
+
private Long id;
|
|
33
|
+
|
|
34
|
+
@Column(name = "account", nullable = false, unique = true, length = 50)
|
|
35
|
+
private String account;
|
|
36
|
+
|
|
37
|
+
@Column(name = "password", nullable = false, length = 100)
|
|
38
|
+
private String password;
|
|
39
|
+
|
|
40
|
+
@Column(name = "display_name", length = 50)
|
|
41
|
+
private String displayName;
|
|
42
|
+
|
|
43
|
+
@Column(name = "authorities", length = 500)
|
|
44
|
+
private String authorities;
|
|
45
|
+
|
|
46
|
+
@Column(name = "enabled", nullable = false)
|
|
47
|
+
private boolean enabled = true;
|
|
48
|
+
|
|
49
|
+
@Column(name = "created_at", insertable = false, updatable = false)
|
|
50
|
+
private LocalDateTime createdAt;
|
|
51
|
+
|
|
52
|
+
@Column(name = "updated_at", insertable = false, updatable = false)
|
|
53
|
+
private LocalDateTime updatedAt;
|
|
54
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package {{basePackage}}.security;
|
|
2
|
+
|
|
3
|
+
import java.util.Arrays;
|
|
4
|
+
import java.util.List;
|
|
5
|
+
import lombok.RequiredArgsConstructor;
|
|
6
|
+
import org.springframework.context.annotation.Primary;
|
|
7
|
+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
|
8
|
+
import org.springframework.security.core.userdetails.UserDetails;
|
|
9
|
+
import org.springframework.security.core.userdetails.UserDetailsService;
|
|
10
|
+
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
|
11
|
+
import org.springframework.stereotype.Service;
|
|
12
|
+
import {{daoPackage}}.AuthUserDAO;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 用 users 表取代 ApplicationConfig 裡那個空的 InMemoryUserDetailsManager。
|
|
16
|
+
* 標 {@code @Primary} 讓它蓋過骨架的預設,不用去改 ApplicationConfig。
|
|
17
|
+
*/
|
|
18
|
+
@Primary
|
|
19
|
+
@Service
|
|
20
|
+
@RequiredArgsConstructor
|
|
21
|
+
public class JpaUserDetailsService implements UserDetailsService {
|
|
22
|
+
|
|
23
|
+
private final AuthUserDAO authUserDAO;
|
|
24
|
+
|
|
25
|
+
@Override
|
|
26
|
+
public UserDetails loadUserByUsername(String account) {
|
|
27
|
+
return authUserDAO.findByAccount(account)
|
|
28
|
+
.map(user -> org.springframework.security.core.userdetails.User.withUsername(user.getAccount())
|
|
29
|
+
.password(user.getPassword())
|
|
30
|
+
.authorities(toAuthorities(user.getAuthorities()))
|
|
31
|
+
.disabled(!user.isEnabled())
|
|
32
|
+
.build())
|
|
33
|
+
// 帳號不存在與密碼錯誤都要回同一種錯誤,否則等於提供帳號列舉的管道。
|
|
34
|
+
.orElseThrow(() -> new UsernameNotFoundException("帳號或密碼錯誤"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private List<SimpleGrantedAuthority> toAuthorities(String raw) {
|
|
38
|
+
if (raw == null || raw.isBlank()) {
|
|
39
|
+
return List.of();
|
|
40
|
+
}
|
|
41
|
+
return Arrays.stream(raw.split(","))
|
|
42
|
+
.map(String::trim)
|
|
43
|
+
.filter(code -> !code.isEmpty())
|
|
44
|
+
.map(SimpleGrantedAuthority::new)
|
|
45
|
+
.toList();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package {{basePackage}}.security;
|
|
2
|
+
|
|
3
|
+
import jakarta.servlet.FilterChain;
|
|
4
|
+
import jakarta.servlet.ServletException;
|
|
5
|
+
import jakarta.servlet.http.HttpServletRequest;
|
|
6
|
+
import jakarta.servlet.http.HttpServletResponse;
|
|
7
|
+
import java.io.IOException;
|
|
8
|
+
import lombok.RequiredArgsConstructor;
|
|
9
|
+
import org.springframework.http.HttpHeaders;
|
|
10
|
+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
11
|
+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
|
12
|
+
import org.springframework.security.core.context.SecurityContextHolder;
|
|
13
|
+
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
|
14
|
+
import org.springframework.stereotype.Component;
|
|
15
|
+
import org.springframework.web.filter.OncePerRequestFilter;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 從 Authorization: Bearer 取出 JWT,驗過就把 Authentication 放進 SecurityContext。
|
|
19
|
+
*
|
|
20
|
+
* <p>驗不過不丟例外也不回 401,只是不設 Authentication,讓後面的授權規則決定——
|
|
21
|
+
* 公開路徑仍然要能在帶著爛 token 的情況下正常回應。
|
|
22
|
+
*/
|
|
23
|
+
@Component
|
|
24
|
+
@RequiredArgsConstructor
|
|
25
|
+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|
26
|
+
|
|
27
|
+
private static final String BEARER_PREFIX = "Bearer ";
|
|
28
|
+
|
|
29
|
+
private final JwtService jwtService;
|
|
30
|
+
|
|
31
|
+
@Override
|
|
32
|
+
protected void doFilterInternal(
|
|
33
|
+
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
|
34
|
+
throws ServletException, IOException {
|
|
35
|
+
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
|
|
36
|
+
if (header != null && header.startsWith(BEARER_PREFIX)
|
|
37
|
+
&& SecurityContextHolder.getContext().getAuthentication() == null) {
|
|
38
|
+
JwtService.AuthenticatedUser user = jwtService.parse(header.substring(BEARER_PREFIX.length()));
|
|
39
|
+
if (user != null) {
|
|
40
|
+
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
|
41
|
+
user.account(),
|
|
42
|
+
null,
|
|
43
|
+
user.authorities().stream().map(SimpleGrantedAuthority::new).toList());
|
|
44
|
+
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
|
45
|
+
SecurityContextHolder.getContext().setAuthentication(authentication);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
filterChain.doFilter(request, response);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package {{basePackage}}.config;
|
|
2
|
+
|
|
3
|
+
import jakarta.validation.constraints.Min;
|
|
4
|
+
import jakarta.validation.constraints.NotBlank;
|
|
5
|
+
import jakarta.validation.constraints.Size;
|
|
6
|
+
import lombok.Getter;
|
|
7
|
+
import lombok.Setter;
|
|
8
|
+
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
9
|
+
import org.springframework.stereotype.Component;
|
|
10
|
+
import org.springframework.validation.annotation.Validated;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* JWT 設定,對應 application.yml 的 birc.auth.*。
|
|
14
|
+
*
|
|
15
|
+
* <p>secret 從 JWT_SECRET 來。沒設時啟動會看專案根的 .env,還是空的就產生一把寫進去。
|
|
16
|
+
* 不要把值寫死在 yml:那會進 git,任何人都能自己簽管理員 token。HS256 要求金鑰
|
|
17
|
+
* 至少 256 bit,所以這裡擋 32 個字元以下。
|
|
18
|
+
*/
|
|
19
|
+
@Getter
|
|
20
|
+
@Setter
|
|
21
|
+
@Validated
|
|
22
|
+
@Component
|
|
23
|
+
@ConfigurationProperties(prefix = "birc.auth")
|
|
24
|
+
public class JwtProperties {
|
|
25
|
+
|
|
26
|
+
@NotBlank
|
|
27
|
+
@Size(min = 32, message = "JWT_SECRET 至少要 32 個字元(HS256 需要 256 bit 金鑰)")
|
|
28
|
+
private String secret;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Token 有效秒數,預設 8 小時。這個骨架沒有 refresh token,過期就重新登入。
|
|
32
|
+
*/
|
|
33
|
+
@Min(60)
|
|
34
|
+
private long expirationSeconds = 28800;
|
|
35
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package {{basePackage}};
|
|
2
|
+
|
|
3
|
+
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
4
|
+
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
5
|
+
|
|
6
|
+
import java.nio.file.Files;
|
|
7
|
+
import java.nio.file.Path;
|
|
8
|
+
import org.junit.jupiter.api.Test;
|
|
9
|
+
import org.springframework.boot.SpringApplication;
|
|
10
|
+
import org.springframework.core.env.StandardEnvironment;
|
|
11
|
+
import {{basePackage}}.config.JwtSecretEnvironmentPostProcessor;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 啟動時補 JWT_SECRET 只有「空的才寫、有了就不換」兩件事。換鑰匙等於所有已發出的 token 作廢。
|
|
15
|
+
*/
|
|
16
|
+
class JwtSecretEnvTest {
|
|
17
|
+
|
|
18
|
+
@Test
|
|
19
|
+
void writesSecretWhenDotEnvIsEmpty() throws Exception {
|
|
20
|
+
Path dir = Files.createTempDirectory("jwt-secret");
|
|
21
|
+
Path env = dir.resolve(".env");
|
|
22
|
+
Files.writeString(env, "JWT_SECRET=\n");
|
|
23
|
+
String previous = System.setProperty("user.dir", dir.toString());
|
|
24
|
+
try {
|
|
25
|
+
StandardEnvironment environment = new StandardEnvironment();
|
|
26
|
+
new JwtSecretEnvironmentPostProcessor().postProcessEnvironment(environment, new SpringApplication());
|
|
27
|
+
|
|
28
|
+
String secret = environment.getProperty("birc.auth.secret");
|
|
29
|
+
assertTrue(secret != null && secret.length() >= 32);
|
|
30
|
+
assertEquals(secret, readSecret(env));
|
|
31
|
+
} finally {
|
|
32
|
+
restoreUserDir(previous);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Test
|
|
37
|
+
void keepsExistingSecret() throws Exception {
|
|
38
|
+
Path dir = Files.createTempDirectory("jwt-keep");
|
|
39
|
+
Path env = dir.resolve(".env");
|
|
40
|
+
String existing = "keep-this-secret-value-32chars-min";
|
|
41
|
+
Files.writeString(env, "JWT_SECRET=" + existing + "\n");
|
|
42
|
+
String previous = System.setProperty("user.dir", dir.toString());
|
|
43
|
+
try {
|
|
44
|
+
StandardEnvironment environment = new StandardEnvironment();
|
|
45
|
+
new JwtSecretEnvironmentPostProcessor().postProcessEnvironment(environment, new SpringApplication());
|
|
46
|
+
|
|
47
|
+
assertEquals(existing, environment.getProperty("birc.auth.secret"));
|
|
48
|
+
assertEquals(existing, readSecret(env));
|
|
49
|
+
} finally {
|
|
50
|
+
restoreUserDir(previous);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private static String readSecret(Path env) throws Exception {
|
|
55
|
+
String text = Files.readString(env);
|
|
56
|
+
int start = text.indexOf("JWT_SECRET=") + "JWT_SECRET=".length();
|
|
57
|
+
int end = text.indexOf('\n', start);
|
|
58
|
+
return (end < 0 ? text.substring(start) : text.substring(start, end)).trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private static void restoreUserDir(String previous) {
|
|
62
|
+
if (previous == null) {
|
|
63
|
+
System.clearProperty("user.dir");
|
|
64
|
+
} else {
|
|
65
|
+
System.setProperty("user.dir", previous);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
package {{basePackage}}.config;
|
|
2
|
+
|
|
3
|
+
import java.io.IOException;
|
|
4
|
+
import java.nio.charset.StandardCharsets;
|
|
5
|
+
import java.nio.file.Files;
|
|
6
|
+
import java.nio.file.Path;
|
|
7
|
+
import java.security.SecureRandom;
|
|
8
|
+
import java.util.Base64;
|
|
9
|
+
import java.util.HashMap;
|
|
10
|
+
import java.util.Map;
|
|
11
|
+
import java.util.regex.Matcher;
|
|
12
|
+
import java.util.regex.Pattern;
|
|
13
|
+
import org.springframework.boot.EnvironmentPostProcessor;
|
|
14
|
+
import org.springframework.boot.SpringApplication;
|
|
15
|
+
import org.springframework.core.env.ConfigurableEnvironment;
|
|
16
|
+
import org.springframework.core.env.MapPropertySource;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 啟動時若 JWT_SECRET 還沒進 Environment,先看專案根的 .env;還是空的就產生一把寫進去。
|
|
20
|
+
* 已有值不改,否則重啟後舊 token 全部驗不過。
|
|
21
|
+
*
|
|
22
|
+
* <p>.env 本身不進 git。不要把產生出來的值抄進 application.yml。
|
|
23
|
+
*/
|
|
24
|
+
public class JwtSecretEnvironmentPostProcessor implements EnvironmentPostProcessor {
|
|
25
|
+
|
|
26
|
+
static final String PROPERTY_SOURCE = "bircJwtSecret";
|
|
27
|
+
private static final String ENV_KEY = "JWT_SECRET";
|
|
28
|
+
private static final String YML_KEY = "birc.auth.secret";
|
|
29
|
+
private static final int MIN_LENGTH = 32;
|
|
30
|
+
private static final Pattern LINE = Pattern.compile("^JWT_SECRET=(.*)$", Pattern.MULTILINE);
|
|
31
|
+
|
|
32
|
+
@Override
|
|
33
|
+
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
|
|
34
|
+
String existing = firstUsable(environment.getProperty(YML_KEY), environment.getProperty(ENV_KEY));
|
|
35
|
+
if (existing != null) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Path envFile = Path.of(System.getProperty("user.dir", "."), ".env");
|
|
40
|
+
String fromFile = readSecret(envFile);
|
|
41
|
+
String secret = firstUsable(fromFile);
|
|
42
|
+
if (secret == null) {
|
|
43
|
+
secret = generate();
|
|
44
|
+
try {
|
|
45
|
+
writeSecret(envFile, secret);
|
|
46
|
+
} catch (IOException ignored) {
|
|
47
|
+
// 這次啟動仍用剛產生的值;檔寫不進去下次會再產生。
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Map<String, Object> values = new HashMap<>();
|
|
52
|
+
values.put(ENV_KEY, secret);
|
|
53
|
+
values.put(YML_KEY, secret);
|
|
54
|
+
environment.getPropertySources().addFirst(new MapPropertySource(PROPERTY_SOURCE, values));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private static String firstUsable(String... values) {
|
|
58
|
+
for (String value : values) {
|
|
59
|
+
if (value != null && value.trim().length() >= MIN_LENGTH) {
|
|
60
|
+
return value.trim();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private static String readSecret(Path envFile) {
|
|
67
|
+
if (!Files.isRegularFile(envFile)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
Matcher match = LINE.matcher(Files.readString(envFile, StandardCharsets.UTF_8));
|
|
72
|
+
return match.find() ? match.group(1).trim() : null;
|
|
73
|
+
} catch (IOException e) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private static void writeSecret(Path envFile, String secret) throws IOException {
|
|
79
|
+
if (!Files.isRegularFile(envFile)) {
|
|
80
|
+
Files.writeString(envFile, ENV_KEY + "=" + secret + "\n", StandardCharsets.UTF_8);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
String text = Files.readString(envFile, StandardCharsets.UTF_8);
|
|
84
|
+
Matcher match = LINE.matcher(text);
|
|
85
|
+
String next;
|
|
86
|
+
if (!match.find()) {
|
|
87
|
+
String suffix = text.endsWith("\n") ? "" : "\n";
|
|
88
|
+
next = text + suffix + ENV_KEY + "=" + secret + "\n";
|
|
89
|
+
} else if (match.group(1).trim().isEmpty()) {
|
|
90
|
+
next = LINE.matcher(text).replaceFirst(ENV_KEY + "=" + Matcher.quoteReplacement(secret));
|
|
91
|
+
} else {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
Files.writeString(envFile, next, StandardCharsets.UTF_8);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private static String generate() {
|
|
98
|
+
byte[] bytes = new byte[48];
|
|
99
|
+
new SecureRandom().nextBytes(bytes);
|
|
100
|
+
return Base64.getEncoder().encodeToString(bytes);
|
|
101
|
+
}
|
|
102
|
+
}
|