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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package {{basePackage}}.controller;
|
|
2
|
+
|
|
3
|
+
import java.util.List;
|
|
4
|
+
import java.util.Map;
|
|
5
|
+
import lombok.RequiredArgsConstructor;
|
|
6
|
+
import org.springframework.web.bind.annotation.GetMapping;
|
|
7
|
+
import org.springframework.web.bind.annotation.PatchMapping;
|
|
8
|
+
import org.springframework.web.bind.annotation.PathVariable;
|
|
9
|
+
import org.springframework.web.bind.annotation.PostMapping;
|
|
10
|
+
import org.springframework.web.bind.annotation.RequestBody;
|
|
11
|
+
import org.springframework.web.bind.annotation.RequestMapping;
|
|
12
|
+
import org.springframework.web.bind.annotation.RequestParam;
|
|
13
|
+
import org.springframework.web.bind.annotation.RestController;
|
|
14
|
+
import {{basePackage}}.client.ClockInClient;
|
|
15
|
+
import {{basePackage}}.dto.clockin.ClockInPage;
|
|
16
|
+
import {{basePackage}}.dto.clockin.ClockInRecord;
|
|
17
|
+
import {{basePackage}}.dto.clockin.OnDutyWeek;
|
|
18
|
+
import {{basePackage}}.dto.clockin.UnclockedMember;
|
|
19
|
+
import {{basePackage}}.dto.clockin.UserPermission;
|
|
20
|
+
import {{basePackage}}.web.Result;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 直接轉打中心簽到系統,沒有自己的資料表。 要加自己的規則(例如限制只能簽自己的帳號)再補一層 service。
|
|
24
|
+
*/
|
|
25
|
+
@RestController
|
|
26
|
+
@RequestMapping("/api/clockin")
|
|
27
|
+
@RequiredArgsConstructor
|
|
28
|
+
public class ClockInController {
|
|
29
|
+
|
|
30
|
+
private final ClockInClient clockInClient;
|
|
31
|
+
|
|
32
|
+
@GetMapping("/today")
|
|
33
|
+
public Result<List<UnclockedMember>> todayUnclocked() {
|
|
34
|
+
return Result.success(clockInClient.findTodayUnclocked());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@GetMapping("/me")
|
|
38
|
+
public Result<UserPermission> me() {
|
|
39
|
+
return Result.success(clockInClient.findMyPermission());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@GetMapping("/onduty")
|
|
43
|
+
public Result<List<OnDutyWeek>> onDuty() {
|
|
44
|
+
return Result.success(clockInClient.findAllOnDuty());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// year / month 不帶就是不限月份,跟簽到系統一致,不要在這裡補今天的年月當預設。
|
|
48
|
+
@GetMapping("/{userAccount}")
|
|
49
|
+
public Result<ClockInPage> memberRecords(
|
|
50
|
+
@PathVariable String userAccount,
|
|
51
|
+
@RequestParam(required = false) Integer year,
|
|
52
|
+
@RequestParam(required = false) Integer month,
|
|
53
|
+
@RequestParam(defaultValue = "1") int nowPage) {
|
|
54
|
+
return Result.success(clockInClient.findMemberRecords(userAccount, year, month, nowPage));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@GetMapping("/students")
|
|
58
|
+
public Result<List<ClockInRecord>> studentRecords(
|
|
59
|
+
@RequestParam(required = false) Integer year, @RequestParam(required = false) Integer month) {
|
|
60
|
+
return Result.success(clockInClient.findStudentRecords(year, month));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@PostMapping("/{stuNumber}")
|
|
64
|
+
public Result<String> clockIn(@PathVariable String stuNumber) {
|
|
65
|
+
return Result.success(clockInClient.clockIn(stuNumber));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@PatchMapping("/clockout/{userAccount}")
|
|
69
|
+
public Result<String> clockOut(@PathVariable String userAccount) {
|
|
70
|
+
return Result.success(clockInClient.clockOut(userAccount));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@PatchMapping("/content/{id}")
|
|
74
|
+
public Result<String> editContent(@PathVariable int id, @RequestBody Map<String, String> body) {
|
|
75
|
+
return Result.success(clockInClient.editContent(id, body.get("content")));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
package {{basePackage}}.dto.clockin;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
4
|
+
import java.util.List;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 分頁的打卡紀錄。分頁欄位用包裝型別:/clockin/students 只回 list、不帶分頁,
|
|
8
|
+
* 那時這三個會是 null,用 int 會在反序列化就失敗。
|
|
9
|
+
*/
|
|
10
|
+
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
11
|
+
public record ClockInPage(List<ClockInRecord> list, Integer totalPages, Long totalElements, Integer currentPage) {}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
package {{basePackage}}.config;
|
|
2
|
+
|
|
3
|
+
import jakarta.validation.constraints.Min;
|
|
4
|
+
import jakarta.validation.constraints.NotBlank;
|
|
5
|
+
import lombok.Getter;
|
|
6
|
+
import lombok.Setter;
|
|
7
|
+
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
8
|
+
import org.springframework.stereotype.Component;
|
|
9
|
+
import org.springframework.validation.annotation.Validated;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 中心簽到系統的連線設定,對應 application.yml 的 birc.clockin.*。
|
|
13
|
+
* account / password 沒有預設值,放環境變數;沒設就等到第一次呼叫才失敗,不擋專案啟動。
|
|
14
|
+
*/
|
|
15
|
+
@Getter
|
|
16
|
+
@Setter
|
|
17
|
+
@Validated
|
|
18
|
+
@Component
|
|
19
|
+
@ConfigurationProperties(prefix = "birc.clockin")
|
|
20
|
+
public class ClockInProperties {
|
|
21
|
+
|
|
22
|
+
@NotBlank
|
|
23
|
+
private String baseUrl = "http://140.131.115.44:50035";
|
|
24
|
+
|
|
25
|
+
private String account = "";
|
|
26
|
+
|
|
27
|
+
private String password = "";
|
|
28
|
+
|
|
29
|
+
@Min(1)
|
|
30
|
+
private int connectTimeoutMs = 3000;
|
|
31
|
+
|
|
32
|
+
@Min(1)
|
|
33
|
+
private int readTimeoutMs = 10000;
|
|
34
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package {{basePackage}}.dto.clockin;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
4
|
+
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 一筆打卡紀錄。date 是 yyyy-MM-dd,clockInTime / clockOutTime 是 yyyy/MM/dd HH:mm:ss,
|
|
8
|
+
* 兩種格式不同,所以先留字串,要算時間再自己 parse。
|
|
9
|
+
*/
|
|
10
|
+
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
11
|
+
public record ClockInRecord(
|
|
12
|
+
Integer id,
|
|
13
|
+
String date,
|
|
14
|
+
String userAccount,
|
|
15
|
+
String name,
|
|
16
|
+
String clockInTime,
|
|
17
|
+
String clockOutTime,
|
|
18
|
+
Double hours,
|
|
19
|
+
String content,
|
|
20
|
+
@JsonProperty("isClockOut") Boolean isClockOut) {}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
package {{basePackage}}.dto.clockin;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
4
|
+
|
|
5
|
+
/** 成員圖檔。filePath 是相對路徑,要接檔案服務的網址(例如 http://140.131.115.44:50035)才拿得到。 */
|
|
6
|
+
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
7
|
+
public record MemberImage(Integer fileNo, String fileName, String filePath) {}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package {{basePackage}}.dto.clockin;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
4
|
+
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
5
|
+
import java.util.List;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 一週的值班。一週兩個人,所以欄位是 1 / 2 兩組,不是清單。
|
|
9
|
+
* startDate / deadline 是 yyyy/MM/dd。isFish / isDinner / isClear 是那週的值班工作有沒有完成。
|
|
10
|
+
*/
|
|
11
|
+
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
12
|
+
public record OnDutyWeek(
|
|
13
|
+
Integer id,
|
|
14
|
+
Integer week,
|
|
15
|
+
String memberId1,
|
|
16
|
+
String userAccount1,
|
|
17
|
+
String name1,
|
|
18
|
+
String memberId2,
|
|
19
|
+
String userAccount2,
|
|
20
|
+
String name2,
|
|
21
|
+
String startDate,
|
|
22
|
+
String deadline,
|
|
23
|
+
@JsonProperty("isFish") Boolean isFish,
|
|
24
|
+
@JsonProperty("isDinner") Boolean isDinner,
|
|
25
|
+
@JsonProperty("isClear") Boolean isClear,
|
|
26
|
+
List<MemberImage> memberImg) {}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
package {{basePackage}}.dto.clockin;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
4
|
+
|
|
5
|
+
/** 今天還沒簽到的成員。category 是群組名,例如「後端群」。 */
|
|
6
|
+
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
7
|
+
public record UnclockedMember(String userAccount, String name, String category) {}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
package {{basePackage}}.dto.clockin;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
4
|
+
import java.util.List;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 目前登入者是誰、權限是什麼。authority 例如 Student、Admin;
|
|
8
|
+
* 要不要顯示全中心的紀錄看這個,不要自己猜。
|
|
9
|
+
*/
|
|
10
|
+
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
11
|
+
public record UserPermission(String userId, String userName, String authority, List<MemberImage> images) {}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
# --- clockin(由 birc-generator add 產生) ---
|
|
3
|
+
# 帳號密碼放環境變數,不要寫進這份檔案。沒設的話第一次呼叫才會失敗,不影響啟動。
|
|
4
|
+
birc:
|
|
5
|
+
clockin:
|
|
6
|
+
base-url: ${BIRC_CLOCKIN_BASE_URL:http://140.131.115.44:50035}
|
|
7
|
+
account: ${BIRC_CLOCKIN_ACCOUNT:}
|
|
8
|
+
password: ${BIRC_CLOCKIN_PASSWORD:}
|
|
9
|
+
connect-timeout-ms: 3000
|
|
10
|
+
read-timeout-ms: 10000
|
|
@@ -44,6 +44,11 @@ services:
|
|
|
44
44
|
TZ: ${TZ:-Asia/Taipei}
|
|
45
45
|
SPRING_PROFILES_ACTIVE: prod
|
|
46
46
|
FILE_STORAGE_PATH: /app/upload
|
|
47
|
+
CORS_ALLOWED_ORIGIN_PATTERNS: ${CORS_ALLOWED_ORIGIN_PATTERNS:-}
|
|
48
|
+
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false}
|
|
49
|
+
# 正式環境預設關掉 API 文件,要開再在部署環境覆寫。
|
|
50
|
+
API_DOCS_ENABLED: ${API_DOCS_ENABLED:-false}
|
|
51
|
+
JWT_SECRET: ${JWT_SECRET:-}
|
|
47
52
|
SENTRY_DSN: ${SENTRY_DSN:-}
|
|
48
53
|
volumes:
|
|
49
54
|
- type: volume
|
|
@@ -10,7 +10,9 @@ services:
|
|
|
10
10
|
MYSQL_PASSWORD: ${DB_PASSWORD}
|
|
11
11
|
TZ: ${TZ:-Asia/Taipei}
|
|
12
12
|
ports:
|
|
13
|
-
|
|
13
|
+
# 開發用資料庫綁 loopback,不要讓同網段的人直接連。要從別台連就改
|
|
14
|
+
# DB_BIND_HOST,或用 SSH tunnel。
|
|
15
|
+
- "${DB_BIND_HOST:-127.0.0.1}:${DB_PORT:-3306}:3306"
|
|
14
16
|
volumes:
|
|
15
17
|
- db_data:/var/lib/mysql
|
|
16
18
|
command:
|
|
@@ -41,6 +43,10 @@ services:
|
|
|
41
43
|
TZ: ${TZ:-Asia/Taipei}
|
|
42
44
|
SPRING_PROFILES_ACTIVE: dev
|
|
43
45
|
FILE_STORAGE_PATH: /app/upload
|
|
46
|
+
CORS_ALLOWED_ORIGIN_PATTERNS: ${CORS_ALLOWED_ORIGIN_PATTERNS:-}
|
|
47
|
+
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false}
|
|
48
|
+
API_DOCS_ENABLED: ${API_DOCS_ENABLED:-true}
|
|
49
|
+
JWT_SECRET: ${JWT_SECRET:-}
|
|
44
50
|
volumes:
|
|
45
51
|
- ./upload:/app/upload
|
|
46
52
|
depends_on:
|
|
@@ -5,6 +5,8 @@ DB_DATABASE={{snakeCase projectNameKebab}}
|
|
|
5
5
|
DB_USER={{snakeCase projectNameKebab}}_user
|
|
6
6
|
DB_PASSWORD={{dbPassword}}
|
|
7
7
|
DB_PORT=3306
|
|
8
|
+
# 開發用資料庫預設只綁 127.0.0.1。要開放同網段連線才改成 0.0.0.0。
|
|
9
|
+
DB_BIND_HOST=127.0.0.1
|
|
8
10
|
|
|
9
11
|
# Spring Boot(bootRun 前先把 .env 載入目前 shell;Bash 用 source,PowerShell 見 README)
|
|
10
12
|
DB_URL=jdbc:mysql://127.0.0.1:3306/{{snakeCase projectNameKebab}}
|
|
@@ -16,6 +18,19 @@ FILE_STORAGE_PATH=./upload
|
|
|
16
18
|
LOG_LEVEL=INFO
|
|
17
19
|
LOG_PATH=./logs
|
|
18
20
|
|
|
21
|
+
# 前端 Origin,用逗號分隔(例如 http://localhost:5173)。留空 = 不開放跨來源。
|
|
22
|
+
# 不要填 *,啟動會失敗。
|
|
23
|
+
CORS_ALLOWED_ORIGIN_PATTERNS=
|
|
24
|
+
# 只有改用 cookie 驗證時才設 true。
|
|
25
|
+
CORS_ALLOW_CREDENTIALS=false
|
|
26
|
+
|
|
27
|
+
# JWT 簽章金鑰,至少 32 個字元。裝了 auth 時 create / add 會寫進 .env;
|
|
28
|
+
# clone 後若 .env 還是空的,第一次啟動也會自己補。不要把填好的 .env 提交。
|
|
29
|
+
JWT_SECRET=
|
|
30
|
+
|
|
31
|
+
# Swagger UI / OpenAPI JSON。正式環境設 false。
|
|
32
|
+
API_DOCS_ENABLED=true
|
|
33
|
+
|
|
19
34
|
# 正式環境映像(docker-compose.prod.yml)
|
|
20
35
|
DOCKER_IMAGE={{snakeCase projectNameKebab}}_app
|
|
21
36
|
APP_TAG=latest
|
|
@@ -11,10 +11,55 @@ public class FileExtensionUtils {
|
|
|
11
11
|
public static final Set<String> IMAGE = Set.of("png", "jpg", "jpeg", "webp");
|
|
12
12
|
public static final Set<String> DOCUMENT = Set.of("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx");
|
|
13
13
|
|
|
14
|
+
private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G'};
|
|
15
|
+
private static final byte[] JPEG = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF};
|
|
16
|
+
private static final byte[] RIFF = {'R', 'I', 'F', 'F'};
|
|
17
|
+
private static final byte[] WEBP = {'W', 'E', 'B', 'P'};
|
|
18
|
+
private static final byte[] PDF = {'%', 'P', 'D', 'F'};
|
|
19
|
+
private static final byte[] ZIP = {'P', 'K', 0x03, 0x04};
|
|
20
|
+
private static final byte[] OLE2 = {(byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 讀開頭這麼多 bytes 就夠判斷所有支援的格式(webp 的簽章在 offset 8)。
|
|
24
|
+
*/
|
|
25
|
+
public static final int HEADER_LENGTH = 12;
|
|
26
|
+
|
|
14
27
|
public void assertAllowed(String fileName, Set<String> allowed) {
|
|
15
28
|
String extension = FileUtils.getFileExtension(fileName).toLowerCase(Locale.ROOT);
|
|
16
29
|
if (extension.isBlank() || !allowed.contains(extension)) {
|
|
17
30
|
throw new FileExtensionIllegalException("不允許的副檔名:" + extension);
|
|
18
31
|
}
|
|
19
32
|
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 副檔名白名單擋不住改名:把 evil.exe 改成 evil.png 就過了。這裡再比對檔案開頭的
|
|
36
|
+
* 格式簽章,內容跟副檔名對不起來就拒絕。沒有已知簽章的副檔名交給白名單判斷。
|
|
37
|
+
*/
|
|
38
|
+
public void assertContentMatchesExtension(String fileName, byte[] header) {
|
|
39
|
+
String extension = FileUtils.getFileExtension(fileName).toLowerCase(Locale.ROOT);
|
|
40
|
+
boolean matches = switch (extension) {
|
|
41
|
+
case "png" -> startsWith(header, 0, PNG);
|
|
42
|
+
case "jpg", "jpeg" -> startsWith(header, 0, JPEG);
|
|
43
|
+
case "webp" -> startsWith(header, 0, RIFF) && startsWith(header, 8, WEBP);
|
|
44
|
+
case "pdf" -> startsWith(header, 0, PDF);
|
|
45
|
+
case "docx", "xlsx", "pptx" -> startsWith(header, 0, ZIP);
|
|
46
|
+
case "doc", "xls", "ppt" -> startsWith(header, 0, OLE2);
|
|
47
|
+
default -> true;
|
|
48
|
+
};
|
|
49
|
+
if (!matches) {
|
|
50
|
+
throw new FileExtensionIllegalException("檔案內容與副檔名 " + extension + " 不符");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private boolean startsWith(byte[] header, int offset, byte[] signature) {
|
|
55
|
+
if (header == null || header.length < offset + signature.length) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
for (int i = 0; i < signature.length; i++) {
|
|
59
|
+
if (header[offset + i] != signature[i]) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
20
65
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package {{basePackage}}.util.file;
|
|
2
|
+
|
|
3
|
+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
|
4
|
+
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
5
|
+
|
|
6
|
+
import java.util.Set;
|
|
7
|
+
import org.junit.jupiter.api.Test;
|
|
8
|
+
import {{basePackage}}.exception.file.FileExtensionIllegalException;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 副檔名白名單擋不住改名,格式簽章才擋得住。這份測試守住兩層都在。
|
|
12
|
+
*/
|
|
13
|
+
class FileExtensionUtilsTest {
|
|
14
|
+
|
|
15
|
+
private static final byte[] PNG_HEADER = {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0};
|
|
16
|
+
private static final byte[] WINDOWS_EXECUTABLE = {'M', 'Z', (byte) 0x90, 0, 0, 0, 0, 0, 0, 0, 0, 0};
|
|
17
|
+
|
|
18
|
+
@Test
|
|
19
|
+
void rejectsExtensionOutsideWhitelist() {
|
|
20
|
+
assertThrows(
|
|
21
|
+
FileExtensionIllegalException.class,
|
|
22
|
+
() -> FileExtensionUtils.assertAllowed("evil.exe", FileExtensionUtils.IMAGE));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Test
|
|
26
|
+
void rejectsExecutableRenamedToPng() {
|
|
27
|
+
// 防住:只驗副檔名時,evil.exe 改名成 evil.png 就能上傳。
|
|
28
|
+
assertThrows(
|
|
29
|
+
FileExtensionIllegalException.class,
|
|
30
|
+
() -> FileExtensionUtils.assertContentMatchesExtension("evil.png", WINDOWS_EXECUTABLE));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Test
|
|
34
|
+
void acceptsRealPng() {
|
|
35
|
+
assertDoesNotThrow(() -> FileExtensionUtils.assertAllowed("photo.png", FileExtensionUtils.IMAGE));
|
|
36
|
+
assertDoesNotThrow(() -> FileExtensionUtils.assertContentMatchesExtension("photo.png", PNG_HEADER));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Test
|
|
40
|
+
void acceptsExtensionsWithoutKnownSignature() {
|
|
41
|
+
// 沒有已知簽章的格式不該被誤擋,交給白名單判斷就好。
|
|
42
|
+
assertDoesNotThrow(
|
|
43
|
+
() -> FileExtensionUtils.assertContentMatchesExtension("notes.txt", WINDOWS_EXECUTABLE));
|
|
44
|
+
assertThrows(
|
|
45
|
+
FileExtensionIllegalException.class,
|
|
46
|
+
() -> FileExtensionUtils.assertAllowed("notes.txt", Set.of("png")));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package {{basePackage}}.service.impl;
|
|
2
2
|
|
|
3
3
|
import java.io.IOException;
|
|
4
|
+
import java.io.InputStream;
|
|
4
5
|
import java.io.UncheckedIOException;
|
|
5
6
|
import java.nio.file.Files;
|
|
6
7
|
import java.nio.file.Path;
|
|
@@ -40,6 +41,11 @@ public class FileStorageServiceImpl implements FileStorageService {
|
|
|
40
41
|
FileExtensionUtils.assertAllowed(originalName, allowed);
|
|
41
42
|
|
|
42
43
|
try {
|
|
44
|
+
try (InputStream in = file.getInputStream()) {
|
|
45
|
+
FileExtensionUtils.assertContentMatchesExtension(
|
|
46
|
+
originalName, in.readNBytes(FileExtensionUtils.HEADER_LENGTH));
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
Path baseDir = Path.of(properties.getBasePath()).toAbsolutePath().normalize();
|
|
44
50
|
Path targetDir = baseDir;
|
|
45
51
|
if (subDirectory != null) {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
package {{basePackage}}.config;
|
|
2
2
|
|
|
3
3
|
import java.util.List;
|
|
4
|
+
import java.util.regex.Pattern;
|
|
5
|
+
import org.springframework.beans.factory.ObjectProvider;
|
|
6
|
+
import org.springframework.beans.factory.annotation.Value;
|
|
4
7
|
import org.springframework.context.annotation.Bean;
|
|
5
8
|
import org.springframework.context.annotation.Configuration;
|
|
9
|
+
import org.springframework.security.config.Customizer;
|
|
6
10
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
|
7
11
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|
8
12
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
|
@@ -10,6 +14,9 @@ import org.springframework.security.config.http.SessionCreationPolicy;
|
|
|
10
14
|
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
|
11
15
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
12
16
|
import org.springframework.security.web.SecurityFilterChain;
|
|
17
|
+
import org.springframework.web.cors.CorsConfiguration;
|
|
18
|
+
import org.springframework.web.cors.CorsConfigurationSource;
|
|
19
|
+
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
|
13
20
|
|
|
14
21
|
@Configuration
|
|
15
22
|
@EnableWebSecurity
|
|
@@ -17,12 +24,60 @@ import org.springframework.security.web.SecurityFilterChain;
|
|
|
17
24
|
public class SecurityConfig {
|
|
18
25
|
|
|
19
26
|
public static final List<String> PUBLIC_PATHS = List.of(
|
|
20
|
-
"/swagger-ui/**",
|
|
21
|
-
"/v3/api-docs/**",
|
|
22
27
|
"/actuator/health",
|
|
23
28
|
"/actuator/health/**"
|
|
24
29
|
);
|
|
25
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Swagger UI 與 OpenAPI JSON。開著等於把所有端點與 schema 攤開給任何人看,
|
|
33
|
+
* 正式環境用 API_DOCS_ENABLED=false 關掉。
|
|
34
|
+
*/
|
|
35
|
+
public static final List<String> API_DOCS_PATHS = List.of(
|
|
36
|
+
"/swagger-ui/**",
|
|
37
|
+
"/swagger-ui.html",
|
|
38
|
+
"/v3/api-docs/**"
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 合法的 Origin 寫法:scheme://host[:port],host 最多只能有一個開頭的 *. 子網域萬用字元。
|
|
43
|
+
*/
|
|
44
|
+
private static final Pattern ORIGIN_SHAPE =
|
|
45
|
+
Pattern.compile("^https?://(\\*\\.)?[a-z0-9-]+(\\.[a-z0-9-]+)*(:\\d+)?$", Pattern.CASE_INSENSITIVE);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 允許跨來源的前端 Origin,來自 application.yml 的 app.cors.allowed-origin-patterns
|
|
49
|
+
* (環境變數 CORS_ALLOWED_ORIGIN_PATTERNS),用逗號分隔。留空代表不開放跨來源。
|
|
50
|
+
*/
|
|
51
|
+
private final List<String> allowedOriginPatterns;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 是否允許前端帶 cookie。本專案是 Bearer Token,Authorization 標頭不需要這個開關,
|
|
55
|
+
* 所以預設關閉;改成 session cookie 驗證時才需要打開。
|
|
56
|
+
*/
|
|
57
|
+
private final boolean allowCredentials;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* API 文件是否開放匿名瀏覽。預設開著方便開發與課堂展示,正式環境請關掉。
|
|
61
|
+
*/
|
|
62
|
+
private final boolean apiDocsEnabled;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 由 feature 或專案自己提供,沒有就是空的,行為跟沒有這個機制時一樣。
|
|
66
|
+
*/
|
|
67
|
+
private final List<SecurityCustomizer> customizers;
|
|
68
|
+
|
|
69
|
+
public SecurityConfig(
|
|
70
|
+
@Value("${app.cors.allowed-origin-patterns:}") List<String> allowedOriginPatterns,
|
|
71
|
+
@Value("${app.cors.allow-credentials:false}") boolean allowCredentials,
|
|
72
|
+
@Value("${app.api-docs.enabled:true}") boolean apiDocsEnabled,
|
|
73
|
+
ObjectProvider<SecurityCustomizer> customizers) {
|
|
74
|
+
this.allowedOriginPatterns =
|
|
75
|
+
allowedOriginPatterns.stream().map(String::trim).filter(pattern -> !pattern.isEmpty()).toList();
|
|
76
|
+
this.allowCredentials = allowCredentials;
|
|
77
|
+
this.apiDocsEnabled = apiDocsEnabled;
|
|
78
|
+
this.customizers = customizers.orderedStream().toList();
|
|
79
|
+
}
|
|
80
|
+
|
|
26
81
|
@Bean
|
|
27
82
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
|
28
83
|
// 本專案是 STATELESS + Bearer Token 的 REST API,CSRF 攻擊需要瀏覽器
|
|
@@ -30,17 +85,77 @@ public class SecurityConfig {
|
|
|
30
85
|
// 若之後改成 session cookie 驗證、或開放瀏覽器直接呼叫的非公開寫入
|
|
31
86
|
// 端點,必須把下面這行換成 CookieCsrfTokenRepository.withHttpOnlyFalse()
|
|
32
87
|
// 並讓前端帶上 X-XSRF-TOKEN。
|
|
33
|
-
|
|
88
|
+
// CORS 必須掛在 Security 這條 chain 上。只在 WebMvcConfigurer 寫
|
|
89
|
+
// addCorsMappings 沒有用:preflight 的 OPTIONS 會先被 Security 擋成 401。
|
|
90
|
+
http.cors(Customizer.withDefaults())
|
|
91
|
+
.csrf(csrf -> csrf.disable())
|
|
34
92
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
35
|
-
.authorizeHttpRequests(auth ->
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
|
|
93
|
+
.authorizeHttpRequests(auth -> {
|
|
94
|
+
auth.requestMatchers(PUBLIC_PATHS.toArray(String[]::new)).permitAll();
|
|
95
|
+
if (apiDocsEnabled) {
|
|
96
|
+
auth.requestMatchers(API_DOCS_PATHS.toArray(String[]::new)).permitAll();
|
|
97
|
+
}
|
|
98
|
+
for (SecurityCustomizer customizer : customizers) {
|
|
99
|
+
List<String> paths = customizer.publicPaths();
|
|
100
|
+
if (!paths.isEmpty()) {
|
|
101
|
+
auth.requestMatchers(paths.toArray(String[]::new)).permitAll();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
auth.anyRequest().authenticated();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
for (SecurityCustomizer customizer : customizers) {
|
|
108
|
+
customizer.customize(http);
|
|
109
|
+
}
|
|
40
110
|
|
|
41
111
|
return http.build();
|
|
42
112
|
}
|
|
43
113
|
|
|
114
|
+
@Bean
|
|
115
|
+
public CorsConfigurationSource corsConfigurationSource() {
|
|
116
|
+
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
|
117
|
+
if (allowedOriginPatterns.isEmpty()) {
|
|
118
|
+
return source;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (String pattern : allowedOriginPatterns) {
|
|
122
|
+
if (isUnsafeOriginPattern(pattern)) {
|
|
123
|
+
throw new IllegalStateException(
|
|
124
|
+
"app.cors.allowed-origin-patterns 不接受會放行任意網站的寫法:" + pattern
|
|
125
|
+
+ "。請列出實際的前端網域,例如 http://localhost:5173,"
|
|
126
|
+
+ "或單層子網域萬用字元 https://*.ntub.edu.tw");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
CorsConfiguration config = new CorsConfiguration();
|
|
131
|
+
// 用 allowedOriginPatterns 而不是 allowedOrigins:後者不接受 https://*.ntub.edu.tw
|
|
132
|
+
// 這種子網域萬用字元,一設就會在啟動時丟例外。
|
|
133
|
+
config.setAllowedOriginPatterns(allowedOriginPatterns);
|
|
134
|
+
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
|
135
|
+
config.setAllowedHeaders(List.of("*"));
|
|
136
|
+
config.setAllowCredentials(allowCredentials);
|
|
137
|
+
config.setMaxAge(3600L);
|
|
138
|
+
source.registerCorsConfiguration("/**", config);
|
|
139
|
+
return source;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 擋掉會放行任意網站的設定。只認明確的 Origin,或 https://*.ntub.edu.tw 這種
|
|
144
|
+
* 開頭單層子網域萬用字元;*、https://*、https://*.*、https://*.com 都會被拒絕。
|
|
145
|
+
* 允許任意 Origin 等於把 API 開放給任何網頁呼叫。
|
|
146
|
+
*/
|
|
147
|
+
private static boolean isUnsafeOriginPattern(String pattern) {
|
|
148
|
+
if (!ORIGIN_SHAPE.matcher(pattern).matches()) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
if (!pattern.contains("*")) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
// *.com、*.tw 幾乎等於全開,萬用字元後面至少要有兩層實名網域。
|
|
155
|
+
String host = pattern.substring(pattern.indexOf("://") + "://".length()).replaceFirst(":\\d+$", "");
|
|
156
|
+
return host.substring("*.".length()).split("\\.").length < 2;
|
|
157
|
+
}
|
|
158
|
+
|
|
44
159
|
@Bean
|
|
45
160
|
public PasswordEncoder passwordEncoder() {
|
|
46
161
|
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
package {{basePackage}}.config;
|
|
2
|
+
|
|
3
|
+
import java.util.List;
|
|
4
|
+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 讓 feature 把自己的東西掛進 SecurityFilterChain,不用改寫 SecurityConfig。
|
|
8
|
+
*
|
|
9
|
+
* <p>沒有任何實作時 SecurityConfig 的行為完全不變。要加登入、JWT、OAuth2 時,
|
|
10
|
+
* 在專案裡寫一個 {@code @Component} 實作這個介面即可:{@code publicPaths()} 回傳
|
|
11
|
+
* 不需要登入的路徑,{@code customize()} 拿到 HttpSecurity 掛 filter。
|
|
12
|
+
*
|
|
13
|
+
* <p>publicPaths 會在 {@code anyRequest().authenticated()} 之前套用——Spring Security
|
|
14
|
+
* 不允許在 anyRequest 之後再加 matcher,所以放行路徑一定要走這個方法,不要在
|
|
15
|
+
* customize() 裡自己呼叫 authorizeHttpRequests。
|
|
16
|
+
*/
|
|
17
|
+
public interface SecurityCustomizer {
|
|
18
|
+
|
|
19
|
+
default List<String> publicPaths() {
|
|
20
|
+
return List.of();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
default void customize(HttpSecurity http) throws Exception {}
|
|
24
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
package {{basePackage}};
|
|
2
|
+
|
|
3
|
+
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
4
|
+
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|
5
|
+
|
|
6
|
+
import java.net.URI;
|
|
7
|
+
import java.net.http.HttpClient;
|
|
8
|
+
import java.net.http.HttpRequest;
|
|
9
|
+
import java.net.http.HttpResponse;
|
|
10
|
+
import org.junit.jupiter.api.Test;
|
|
11
|
+
import org.springframework.boot.test.context.SpringBootTest;
|
|
12
|
+
import org.springframework.boot.test.web.server.LocalServerPort;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* API 文件開著等於公開所有端點與 schema。預設開著方便開發,但一定要關得掉,
|
|
16
|
+
* 而且關掉之後連匿名讀 /v3/api-docs 都不行。
|
|
17
|
+
*/
|
|
18
|
+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
|
19
|
+
class ApiDocsAccessTest {
|
|
20
|
+
|
|
21
|
+
@LocalServerPort
|
|
22
|
+
private int port;
|
|
23
|
+
|
|
24
|
+
@Test
|
|
25
|
+
void apiDocsAreReadableByDefault() throws Exception {
|
|
26
|
+
assertEquals(200, ApiDocs.status(port));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@SpringBootTest(
|
|
31
|
+
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
|
32
|
+
properties = "app.api-docs.enabled=false")
|
|
33
|
+
class ApiDocsDisabledTest {
|
|
34
|
+
|
|
35
|
+
@LocalServerPort
|
|
36
|
+
private int port;
|
|
37
|
+
|
|
38
|
+
@Test
|
|
39
|
+
void apiDocsAreNotReadableWhenDisabled() throws Exception {
|
|
40
|
+
// 防住:開關失效、或有人把 swagger 路徑塞回 PUBLIC_PATHS。
|
|
41
|
+
// 註:Security 擋下會是 401、springdoc 關掉會是 404,從匿名端看不出是哪一層,
|
|
42
|
+
// 這條只保證「關掉之後讀不到」,兩層各自的責任由 SecurityConfig 的斷言分擔。
|
|
43
|
+
assertNotEquals(200, ApiDocs.status(port));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
final class ApiDocs {
|
|
48
|
+
|
|
49
|
+
private ApiDocs() {}
|
|
50
|
+
|
|
51
|
+
static int status(int port) throws Exception {
|
|
52
|
+
HttpRequest request = HttpRequest.newBuilder()
|
|
53
|
+
.uri(URI.create("http://localhost:" + port + "/v3/api-docs"))
|
|
54
|
+
.GET()
|
|
55
|
+
.build();
|
|
56
|
+
HttpResponse<String> response =
|
|
57
|
+
HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
|
|
58
|
+
return response.statusCode();
|
|
59
|
+
}
|
|
60
|
+
}
|