diff --git a/docs/superpowers/plans/2026-08-03-issuance-request-implementation.md b/docs/superpowers/plans/2026-08-03-issuance-request-implementation.md new file mode 100644 index 0000000..fde97ef --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-issuance-request-implementation.md @@ -0,0 +1,500 @@ +# 数字货币发行请求模块 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the first-step digital-currency issuance workflow with separate commercial-bank and central-bank APIs, real SM3/SM2 teaching operations, and Chinese Swagger documentation. + +**Architecture:** Extend the existing `issuance` bounded context around one `IssuanceRequest` aggregate. Commercial-bank APIs own request creation and state transitions; central-bank APIs are a read-only projection of the same request, with a separate receive-status field. MyBatis-Plus persists the aggregate and denomination children; `schema.sql` remains the only idempotent database bootstrap mechanism. + +**Tech Stack:** Java 8, Spring Boot 2.7.18, Spring Security 5.7, MyBatis-Plus 3.5.17, Springdoc 1.8.0, MySQL 8, Bouncy Castle `bcprov-jdk18on:1.84`. + +## Global Constraints + +- Use Java 8 only; do not introduce records, `List.of`, text blocks, `jakarta.*`, or Java 9+ APIs. +- Keep all issuance code below `com.yau.digitalrmb.issuance`; do not place issuance business code in `shared`. +- All commercial-bank and central-bank APIs require a valid JWT, with no teacher/student role restriction. +- Do not restore Flyway. Schema and demo data changes belong in the idempotent `src/main/resources/schema.sql` and must work on MySQL 8 and H2 MySQL mode. +- Do not accept, log, persist, or return a private key. The only exposed signing-key identifier is `sm2-key-02`. +- Swagger labels, summaries, descriptions, field documentation, and root title must be Chinese UTF-8 without garbled characters. +- All work starts from a clean isolated worktree and ends with JDK 8 `mvn test -B`, `mvn package -DskipTests -B`, dev-profile startup, `/actuator/health`, `/v3/api-docs`, and Swagger UI verification. + +--- + +## Target File Structure + +```text +src/main/java/com/yau/digitalrmb/issuance/ + domain/model/ + DenominationItem.java + IssuanceRequest.java + IssuanceRequestStatus.java + CentralBankReceiveStatus.java + domain/repository/ + IssuanceRequestRepository.java + domain/service/ + IssuanceMessageComposer.java + IssuanceSignatureService.java + SignedIssuancePayload.java + application/command/ + CreateIssuanceRequestCommand.java + UpdateIssuanceRequestCommand.java + application/query/ + CommercialBankInventoryView.java + IssuanceRequestView.java + application/service/ + CommercialBankIssuanceApplicationService.java + CentralBankIssuanceQueryService.java + infrastructure/crypto/ + BouncyCastleIssuanceSignatureService.java + InMemorySm2SigningKeyProvider.java + infrastructure/persistence/entity/ + IssuanceBankInventoryEntity.java + IssuanceRequestEntity.java + IssuanceRequestDenominationEntity.java + infrastructure/persistence/mapper/ + IssuanceBankInventoryMapper.java + IssuanceRequestMapper.java + IssuanceRequestDenominationMapper.java + infrastructure/persistence/repository/ + MybatisIssuanceRequestRepository.java + interfaces/dto/ + CreateIssuanceRequestRequest.java + UpdateIssuanceRequestRequest.java + DenominationItemRequest.java + IssuanceRequestResponse.java + CommercialBankInventoryResponse.java + CentralBankIssuanceRequestResponse.java + interfaces/rest/ + CommercialBankIssuanceController.java + CentralBankIssuanceController.java +``` + +## Task 1: Model the Issuance Request Aggregate + +**Files:** +- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/model/DenominationItem.java` +- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequest.java` +- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestStatus.java` +- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/model/CentralBankReceiveStatus.java` +- Create: `src/test/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestTest.java` + +**Interfaces:** +- Consumes: existing `IssuanceApplicationId`. +- Produces: aggregate methods `updateDraft(...)`, `prepareMessage(...)`, `recordDigest(...)`, `recordSignature(...)`, `packagePayload(...)`, and `sendToCentralBank(Instant)`. + +- [ ] **Step 1: Write failing aggregate tests** + +```java +@Test +void rejectsDenominationsWhoseTotalDoesNotMatchRequestAmount() { + assertThatThrownBy(() -> IssuanceRequest.create(id, "BKCHCNBJ00001", "ORG_3A4B5C6D7E8F", + new BigDecimal("50000.00"), "DC", Arrays.asList(new DenominationItem(100, 400)))) + .isInstanceOf(IllegalArgumentException.class); +} + +@Test +void sendsOnlyPackagedRequestAndMarksCentralBankAsReceived() { + IssuanceRequest request = preparedSignedAndPackagedRequest(); + request.sendToCentralBank(Instant.parse("2026-08-03T12:00:00Z")); + assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.SENT); + assertThat(request.getCentralBankReceiveStatus()).isEqualTo(CentralBankReceiveStatus.RECEIVED); +} +``` + +- [ ] **Step 2: Run the aggregate test to verify it fails** + +Run: `mvn -Dtest=IssuanceRequestTest test -B` + +Expected: compilation fails because `IssuanceRequest`, status enums, and `DenominationItem` do not yet exist. + +- [ ] **Step 3: Implement immutable value data and aggregate transitions** + +```java +public enum IssuanceRequestStatus { DRAFT, MESSAGE_PREPARED, DIGESTED, SIGNED, PACKAGED, SENT } +public enum CentralBankReceiveStatus { NOT_RECEIVED, RECEIVED } + +public void sendToCentralBank(Instant receivedAt) { + requireStatus(IssuanceRequestStatus.PACKAGED); + this.status = IssuanceRequestStatus.SENT; + this.centralBankReceiveStatus = CentralBankReceiveStatus.RECEIVED; + this.centralBankReceivedAt = receivedAt; +} +``` + +`DenominationItem` validates positive denomination and quantity. `IssuanceRequest.create(...)` validates exact `Σ(denomination × quantity) == totalAmount`, initializes `DRAFT` and `NOT_RECEIVED`, and normalizes monetary values to scale 2. + +- [ ] **Step 4: Run aggregate tests to verify they pass** + +Run: `mvn -Dtest=IssuanceRequestTest test -B` + +Expected: PASS, including invalid amount, invalid state, normal state progression, and idempotent repeated send tests. + +- [ ] **Step 5: Commit the aggregate model** + +```bash +git add src/main/java/com/yau/digitalrmb/issuance/domain/model src/test/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestTest.java +git commit -m "feat: add issuance request aggregate" +``` + +## Task 2: Implement SM3 and SM2 Teaching Services + +**Files:** +- Modify: `pom.xml` +- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceMessageComposer.java` +- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceSignatureService.java` +- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/service/SignedIssuancePayload.java` +- Create: `src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/InMemorySm2SigningKeyProvider.java` +- Create: `src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureService.java` +- Create: `src/test/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureServiceTest.java` + +**Interfaces:** +- Consumes: `IssuanceRequest` field values and fixed key reference `sm2-key-02`. +- Produces: `compose(...)` plain text and `sign(String keyRef, String plainText)` returning a SM3 hex digest plus Base64 SM2 signature. + +- [ ] **Step 1: Write failing cryptography tests** + +```java +@Test +void composesPrototypeCompatiblePlainTextInDescendingDenominationOrder() { + assertThat(composer.compose("BKCHCNBJ00001", "ORG_3A4B5C6D7E8F", new BigDecimal("50000.00"), + Arrays.asList(new DenominationItem(50, 100), new DenominationItem(100, 400)), "DC", "20260801103218")) + .isEqualTo("ISSUE|BKCHCNBJ00001|ORG_3A4B5C6D7E8F|50000.00|100:400,50:100|DC|20260801103218"); +} + +@Test +void signsWithSm3AndSm2UsingTheTeachingKey() { + SignedIssuancePayload payload = service.sign("sm2-key-02", "ISSUE|..."); + assertThat(payload.getDigest()).matches("[0-9A-F]{64}"); + assertThat(service.verify("sm2-key-02", "ISSUE|...", payload)).isTrue(); +} +``` + +- [ ] **Step 2: Run the cryptography tests to verify they fail** + +Run: `mvn -Dtest=BouncyCastleIssuanceSignatureServiceTest test -B` + +Expected: compilation fails because the composer, payload, signing service, and Bouncy Castle provider are absent. + +- [ ] **Step 3: Add the provider and minimal implementations** + +Add the explicit dependency: + +```xml + + org.bouncycastle + bcprov-jdk18on + 1.84 + +``` + +Register `BouncyCastleProvider` once. Generate an in-memory `sm2p256v1` key pair for `sm2-key-02`; use `MessageDigest.getInstance("SM3", "BC")` for the upper-case hexadecimal digest and `Signature.getInstance("SM3withSM2", "BC")` for signing and verification. The `IssuanceMessageComposer` sorts denominations descending and uses `toPlainString()` for the scale-2 total amount. + +- [ ] **Step 4: Run the cryptography tests to verify they pass** + +Run: `mvn -Dtest=BouncyCastleIssuanceSignatureServiceTest test -B` + +Expected: PASS; the digest has 64 uppercase hexadecimal characters and the generated signature verifies with the in-memory public key. + +- [ ] **Step 5: Commit the cryptography teaching service** + +```bash +git add pom.xml src/main/java/com/yau/digitalrmb/issuance/domain/service src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto src/test/java/com/yau/digitalrmb/issuance/infrastructure/crypto +git commit -m "feat: add issuance sm3 sm2 teaching service" +``` + +## Task 3: Add Idempotent Schema and MyBatis Persistence + +**Files:** +- Modify: `src/main/resources/schema.sql` +- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/repository/IssuanceRequestRepository.java` +- Create: the six entity, mapper, and repository files listed in Target File Structure under `infrastructure/persistence` +- Create: `src/test/java/com/yau/digitalrmb/issuance/infrastructure/persistence/MybatisIssuanceRequestRepositoryTest.java` + +**Interfaces:** +- Consumes: `IssuanceRequestRepository.save(IssuanceRequest)` and `findById(IssuanceApplicationId)`. +- Produces: persistent requests with all denomination entries and persistent commercial-bank inventory lookups. + +- [ ] **Step 1: Write failing persistence tests** + +```java +@Test +void savesAndRestoresTheRequestWithItsDenominationsAndCentralBankStatus() { + repository.save(sentRequest); + IssuanceRequest restored = repository.findById(sentRequest.getId()).get(); + assertThat(restored.getDenominations()).containsExactly(new DenominationItem(100, 400), new DenominationItem(50, 100)); + assertThat(restored.getCentralBankReceiveStatus()).isEqualTo(CentralBankReceiveStatus.RECEIVED); +} + +@Test +void readsSuggestedAmountAsThresholdMinusCurrentBalance() { + assertThat(inventoryQuery.findByBankCode("BKCHCNBJ00001").getSuggestedSupplementAmount()) + .isEqualByComparingTo("50000.00"); +} +``` + +- [ ] **Step 2: Run persistence tests to verify they fail** + +Run: `mvn -Dtest=MybatisIssuanceRequestRepositoryTest test -B` + +Expected: compilation fails because the repository and mappers do not exist. + +- [ ] **Step 3: Add schema and persistence mapping** + +Append idempotent MySQL/H2-compatible DDL to `schema.sql`: + +```sql +CREATE TABLE IF NOT EXISTS issuance_bank_inventory ( + bank_code VARCHAR(32) PRIMARY KEY, + current_balance DECIMAL(20, 2) NOT NULL, + warning_threshold DECIMAL(20, 2) NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS issuance_request ( + id CHAR(36) PRIMARY KEY, + request_no VARCHAR(64) NOT NULL UNIQUE, + bank_code VARCHAR(32) NOT NULL, + organization_id VARCHAR(64) NOT NULL, + total_amount DECIMAL(20, 2) NOT NULL, + currency VARCHAR(16) NOT NULL, + request_timestamp VARCHAR(32), + message_text TEXT, + digest CHAR(64), + signature TEXT, + signing_key_ref VARCHAR(64), + payload_json TEXT, + status VARCHAR(32) NOT NULL, + central_receive_status VARCHAR(32) NOT NULL, + central_received_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS issuance_request_denomination ( + request_id CHAR(36) NOT NULL, + denomination INT NOT NULL, + quantity INT NOT NULL, + PRIMARY KEY (request_id, denomination) +); +``` + +Seed `BKCHCNBJ00001` with `49950000.00` balance and `50000000.00` threshold using an idempotent upsert. Map the root row and denomination rows in the repository; replacing denomination rows occurs only inside a repository save transaction. + +- [ ] **Step 4: Run persistence tests to verify they pass** + +Run: `mvn -Dtest=MybatisIssuanceRequestRepositoryTest test -B` + +Expected: PASS against the existing H2 MySQL-mode test profile. + +- [ ] **Step 5: Commit schema and persistence** + +```bash +git add src/main/resources/schema.sql src/main/java/com/yau/digitalrmb/issuance/domain/repository src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence src/test/java/com/yau/digitalrmb/issuance/infrastructure/persistence +git commit -m "feat: persist issuance requests and bank inventory" +``` + +## Task 4: Add Commercial-Bank and Central-Bank Application Services + +**Files:** +- Create: all command, query, and application-service files listed in Target File Structure +- Create: `src/test/java/com/yau/digitalrmb/issuance/application/service/CommercialBankIssuanceApplicationServiceTest.java` +- Create: `src/test/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceQueryServiceTest.java` + +**Interfaces:** +- Consumes: domain aggregate, repository, message composer, signing service, and authenticated account name. +- Produces: `create`, `update`, `prepareMessage`, `digest`, `sign`, `packagePayload`, `send`, `getCommercialBankView`, `getInventory`, and `getCentralBankView` application methods. + +- [ ] **Step 1: Write failing application-service tests** + +```java +@Test +void processesACommercialBankRequestThroughSendAndMakesItVisibleToCentralBank() { + UUID id = service.create(createCommand, "tzs001").getId(); + service.prepareMessage(id, "tzs001"); + service.digest(id, "tzs001"); + service.sign(id, "tzs001"); + service.packagePayload(id, "tzs001"); + service.send(id, "tzs001"); + assertThat(centralBankService.get(id).getReceiveStatus()).isEqualTo("RECEIVED"); +} + +@Test +void rejectsUpdateAfterMessagePreparation() { + UUID id = createAndPrepareRequest(); + assertThatThrownBy(() -> service.update(id, changedCommand, "tzs001")) + .isInstanceOf(BusinessException.class); +} +``` + +- [ ] **Step 2: Run application-service tests to verify they fail** + +Run: `mvn -Dtest=CommercialBankIssuanceApplicationServiceTest,CentralBankIssuanceQueryServiceTest test -B` + +Expected: compilation fails because the command, query, and application services do not exist. + +- [ ] **Step 3: Implement application orchestration and error mapping** + +Generate request numbers using `ISSUE_REQ_` plus a UUID-derived upper-case suffix. Wrap writes in `@Transactional`; read inventory and central-bank data with dedicated query methods. Convert missing aggregate lookups into `BusinessException(ErrorCode.RESOURCE_NOT_FOUND, ...)`; convert domain `IllegalStateException` and `IllegalArgumentException` into `BusinessException(ErrorCode.VALIDATION_ERROR, ...)`. Make `send` return the existing `SENT` result when called again. + +- [ ] **Step 4: Run application-service tests to verify they pass** + +Run: `mvn -Dtest=CommercialBankIssuanceApplicationServiceTest,CentralBankIssuanceQueryServiceTest test -B` + +Expected: PASS; a commercial-bank send produces a central-bank `RECEIVED` view and draft editing is rejected after preparation. + +- [ ] **Step 5: Commit application services** + +```bash +git add src/main/java/com/yau/digitalrmb/issuance/application src/test/java/com/yau/digitalrmb/issuance/application +git commit -m "feat: add issuance application workflow" +``` + +## Task 5: Expose Separate Chinese-Swagger REST Ends + +**Files:** +- Create: all DTO and controller files listed in Target File Structure +- Modify: `src/main/java/com/yau/digitalrmb/shared/config/OpenApiConfig.java` +- Create: `src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/CommercialBankIssuanceControllerTest.java` +- Create: `src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/CentralBankIssuanceControllerTest.java` + +**Interfaces:** +- Consumes: application service methods and current authenticated JWT subject. +- Produces: the ten REST paths specified in the approved design, wrapped in `ApiResponse`. + +- [ ] **Step 1: Write failing MVC and OpenAPI documentation tests** + +```java +@Test +void commercialBankCreateRequiresJwtAndReturnsDraftRequest() throws Exception { + mockMvc.perform(post("/api/v1/commercial-banks/issuance/requests") + .with(jwt().jwt(jwt -> jwt.subject("tzs001"))) + .contentType(MediaType.APPLICATION_JSON) + .content(validCreateBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DRAFT")); +} + +@Test +void apiDocsContainChineseCommercialAndCentralBankTags() throws Exception { + mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("数字货币发行模块 - 商业银行端"))) + .andExpect(content().string(containsString("数字货币发行模块 - 中央银行端"))); +} +``` + +- [ ] **Step 2: Run controller tests to verify they fail** + +Run: `mvn -Dtest=CommercialBankIssuanceControllerTest,CentralBankIssuanceControllerTest test -B` + +Expected: compilation fails because the controllers and DTOs do not exist. + +- [ ] **Step 3: Implement controllers, DTO validation, and Chinese Swagger metadata** + +Use the two class-level tags and paths exactly: + +```java +@Tag(name = "数字货币发行模块 - 商业银行端", description = "商业银行发起和处理数字货币发行申请") +@RequestMapping("/api/v1/commercial-banks/issuance") + +@Tag(name = "数字货币发行模块 - 中央银行端", description = "中央银行查看数字货币发行请求接收结果") +@RequestMapping("/api/v1/central-banks/issuance") +``` + +Annotate each endpoint with Chinese `@Operation(summary = ..., description = ...)` and each DTO property with Chinese `@Schema(description = ..., example = ...)`. Set the root OpenAPI title to `数字人民币教学仿真后端` using UTF-8 source text. Commercial-bank controllers expose inventory, CRUD-read/update, process actions, and send; the central-bank controller exposes only `GET /requests/{id}`. + +- [ ] **Step 4: Run controller tests to verify they pass** + +Run: `mvn -Dtest=CommercialBankIssuanceControllerTest,CentralBankIssuanceControllerTest test -B` + +Expected: PASS; all commercial-bank write operations require JWT, central-bank query is isolated to its prefix, and `/v3/api-docs` has both readable Chinese tags. + +- [ ] **Step 5: Commit REST and Swagger work** + +```bash +git add src/main/java/com/yau/digitalrmb/issuance/interfaces src/main/java/com/yau/digitalrmb/shared/config/OpenApiConfig.java src/test/java/com/yau/digitalrmb/issuance/interfaces +git commit -m "feat: expose commercial and central issuance APIs" +``` + +## Task 6: Verify the Full Workflow on the Dev Database + +**Files:** +- Modify: `README.md` +- Test: existing and new `src/test/java/com/yau/digitalrmb/issuance/**` tests + +**Interfaces:** +- Consumes: completed application JAR and `dev` profile. +- Produces: documented startup and verified commercial-bank-to-central-bank workflow. + +- [ ] **Step 1: Write a failing integration workflow test** + +```java +@Test +void completeTeachingWorkflowReturnsReceivedPayloadToCentralBankEnd() throws Exception { + UUID requestId = createPrepareDigestSignPackageAndSendThroughCommercialBankApi(); + mockMvc.perform(get("/api/v1/central-banks/issuance/requests/{id}", requestId) + .with(jwt().jwt(jwt -> jwt.subject("tzs001")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.receiveStatus").value("RECEIVED")) + .andExpect(jsonPath("$.data.payload.requestId").exists()); +} +``` + +- [ ] **Step 2: Run the full workflow test to verify it fails before the missing integration behavior is completed** + +Run: `mvn -Dtest=IssuanceWorkflowIntegrationTest test -B` + +Expected: FAIL until all commercial-bank action endpoints and the central-bank projection are connected. + +- [ ] **Step 3: Add only the missing integration wiring and document the dev workflow** + +Add a README section with the `dev` profile startup command, the Chinese Swagger URL, the commercial-bank inventory endpoint, and the central-bank request query endpoint. Do not document or expose any database password or private key. + +- [ ] **Step 4: Run all automated verification** + +Run: + +```powershell +$env:JAVA_HOME='C:\Users\Acer\.jdks\temurin-8\jdk8u502-b07' +$env:Path="$env:JAVA_HOME\bin;$env:Path" +mvn test -B +mvn package -DskipTests -B +``` + +Expected: Maven exits 0 with all tests passing and the JAR built for Java 8. + +- [ ] **Step 5: Run the dev-profile smoke test against the 118 test database** + +Run: + +```powershell +java -jar target\digital-rmb-backend-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev --server.port=8081 +Invoke-RestMethod http://localhost:8081/actuator/health +Invoke-WebRequest http://localhost:8081/swagger-ui/index.html -UseBasicParsing +Invoke-WebRequest http://localhost:8081/v3/api-docs -UseBasicParsing +``` + +Expected: health status is `UP`, Swagger returns HTTP 200, and OpenAPI JSON contains both readable Chinese tag names without replacement characters. + +- [ ] **Step 6: Commit verification and documentation** + +```bash +git add README.md src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceWorkflowIntegrationTest.java +git commit -m "test: verify issuance teaching workflow" +``` + +## Plan Self-Review + +- Spec coverage: Tasks 1–4 implement the aggregate, two-end state separation, inventory, persistence, message construction, SM3/SM2, and send simulation. Task 5 implements all approved API paths and Chinese Swagger grouping. Task 6 covers full workflow, JDK 8 build, dev database startup, and non-garbled OpenAPI verification. +- Placeholder scan: no tasks defer behavior; each has concrete paths, interfaces, test commands, expected results, and implementation details. +- Type consistency: all request IDs are UUID-backed `IssuanceApplicationId`; persistence and HTTP boundaries expose UUID values, while application services accept UUID and adapt to the value object internally. The status names are consistently `DRAFT`, `MESSAGE_PREPARED`, `DIGESTED`, `SIGNED`, `PACKAGED`, `SENT`, `NOT_RECEIVED`, and `RECEIVED`. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-08-03-issuance-request-implementation.md`. + +1. **Subagent-Driven (recommended)** — dispatch a fresh subagent per task and review between tasks. +2. **Inline Execution** — execute tasks in this session in batches with review checkpoints. diff --git a/docs/superpowers/plans/2026-08-04-current-user-context.md b/docs/superpowers/plans/2026-08-04-current-user-context.md new file mode 100644 index 0000000..09fd0b7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-current-user-context.md @@ -0,0 +1,185 @@ +# Current User Context Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** Add no-argument current-user access for backend code and return the same complete profile from GET /api/v1/auth/me. + +**Architecture:** PlatformTokenVerifier parses verified profile claims into VerifiedPlatformToken. LocalSsoAccountService saves the profile in platform_user_snapshot. CurrentUserService reads that snapshot from the Jwt in SecurityContext, and AuthController delegates to it. + +**Tech Stack:** Java 8, Spring Boot 2.7, Spring Security, MyBatis-Plus, H2, JUnit 5, MockMvc. + +## Global Constraints + +- Keep Java 8 and ApiResponse. +- userId and raw roleid are long; school, college, major, class, and student identifiers are String. +- Do not put profile data in application JWTs or expose passwords or Tokens. +- Business requests only read the local snapshot. + +--- + +### Task 1: Parse complete profile claims + +**Files:** +- Modify: src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java +- Modify: src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java +- Modify: src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java + +**Interfaces:** VerifiedPlatformToken gains schoolId, schoolName, collegeId, collegeName, majorId, majorName, roleId, name, classId, className, studentId. + +- [ ] **Step 1: Write the failing test** + +~~~java +@Test +void parsesCompleteUserProfileFromVerifiedToken() throws Exception { + VerifiedPlatformToken token = verifier.verify(token(487L, "tzs001", "new-password", 2L, + now.plus(Duration.ofMinutes(5)), completeProfile())); + + assertThat(token.getSchoolId()).isEqualTo("610000"); + assertThat(token.getCollegeName()).isEqualTo("Computer College"); + assertThat(token.getMajorName()).isEqualTo("Software Engineering"); + assertThat(token.getRoleId()).isEqualTo(2L); + assertThat(token.getName()).isEqualTo("Test Student"); + assertThat(token.getClassId()).isEqualTo("202401"); + assertThat(token.getStudentId()).isEqualTo("20240001"); +} +~~~ + +- [ ] **Step 2: Verify red** + +Run: mvn -Dtest=PlatformTokenVerifierTest test -DforkCount=0 -B +Expected: FAIL because the new getters do not exist. + +- [ ] **Step 3: Minimal implementation** + +Add immutable fields and a full constructor to VerifiedPlatformToken while retaining the old constructor for existing callers. In PlatformTokenVerifier, preserve parsed roleid as roleId; parse name with username fallback and all remaining profile fields via: + +~~~java +private String optionalText(JsonNode payload, String field) { + JsonNode node = payload.path(field); + return node.isMissingNode() || node.isNull() ? null : node.asText(); +} +~~~ + +- [ ] **Step 4: Verify green** + +Run: mvn -Dtest=PlatformTokenVerifierTest test -DforkCount=0 -B +Expected: PASS. + +### Task 2: Persist the profile snapshot + +**Files:** +- Modify: src/main/resources/schema.sql +- Modify: src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java +- Modify: src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java +- Modify: src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java + +**Interfaces:** synchronize(VerifiedPlatformToken) upserts every complete profile field in platform_user_snapshot. + +- [ ] **Step 1: Write the failing test** + +~~~java +@Test +void synchronizesCompleteProfileIntoLocalSnapshot() { + service.synchronize(completeToken(603L, "sso603", 2L)); + + Map row = new JdbcTemplate(dataSource).queryForMap( + "SELECT school_id, college_name, major_name, role_id, class_name, student_id " + + "FROM platform_user_snapshot WHERE platform_user_id = 603"); + assertThat(row).containsEntry("school_id", "610000") + .containsEntry("college_name", "Computer College") + .containsEntry("major_name", "Software Engineering") + .containsEntry("role_id", 2L) + .containsEntry("class_name", "Class 1") + .containsEntry("student_id", "20240001"); +} +~~~ + +- [ ] **Step 2: Verify red** + +Run: mvn -Dtest=LocalSsoAccountServiceTest test -DforkCount=0 -B +Expected: FAIL because the columns do not exist. + +- [ ] **Step 3: Minimal implementation** + +Add nullable school_id, school_name, college_id, college_name, major_id, major_name, role_id, class_id, class_name, student_id columns to the table creation and idempotent ADD COLUMN IF NOT EXISTS statements afterward. Map them with TableField in the snapshot Entity and copy token fields in upsertSnapshot. + +- [ ] **Step 4: Verify green** + +Run: mvn -Dtest=LocalSsoAccountServiceTest test -DforkCount=0 -B +Expected: PASS. + +### Task 3: Implement the common current-user service + +**Files:** +- Create: src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java +- Create: src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java +- Create: src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java + +**Interfaces:** CurrentUserService.getCurrentUser() returns schoolId, schoolName, collegeId, collegeName, majorId, majorName, roleid, userId, username, name, classId, className, studentid. + +- [ ] **Step 1: Write the failing test** + +Synchronize a complete profile, put JwtAuthenticationToken for that user into SecurityContextHolder, call currentUserService.getCurrentUser(), and assert all fields. Also test no snapshot and assert BusinessException ErrorCode.UNAUTHORIZED. Clear SecurityContextHolder in AfterEach. + +- [ ] **Step 2: Verify red** + +Run: mvn -Dtest=CurrentUserServiceTest test -DforkCount=0 -B +Expected: FAIL because the service does not exist. + +- [ ] **Step 3: Minimal implementation** + +~~~java +public CurrentUser getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof Jwt)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份无效"); + } + long userId = parseUserId((Jwt) authentication.getPrincipal()); + PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(userId); + if (snapshot == null) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在"); + } + return map(snapshot); +} +~~~ + +map converts the complete snapshot, and parseUserId maps malformed subjects to UNAUTHORIZED. + +- [ ] **Step 4: Verify green** + +Run: mvn -Dtest=CurrentUserServiceTest test -DforkCount=0 -B +Expected: PASS. + +### Task 4: Extend /auth/me + +**Files:** +- Modify: src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java +- Modify: src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java +- Modify: src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java +- Modify: src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java + +**Interfaces:** GET /api/v1/auth/me returns exactly the 13 CurrentUser fields in data. + +- [ ] **Step 1: Write the failing endpoint test** + +Synchronize complete Token data and assert data.userId, username, name, schoolId, schoolName, collegeId, collegeName, majorId, majorName, roleid, classId, className, studentid. Update local login assertion to username. + +- [ ] **Step 2: Verify red** + +Run: mvn -Dtest=CurrentUserAndLogoutTest,AuthControllerTest test -DforkCount=0 -B +Expected: FAIL because the response DTO lacks the fields. + +- [ ] **Step 3: Minimal implementation** + +Replace CurrentUserResponse with the 13 CurrentUser fields. Inject CurrentUserService and have /me map currentUserService.getCurrentUser() into the response; do not query the Mapper in that method. + +- [ ] **Step 4: Verify green and build** + +Run: mvn -Dtest=CurrentUserAndLogoutTest,AuthControllerTest test -DforkCount=0 -B +Expected: PASS. + +Run: mvn test -DforkCount=0 -B +Expected: PASS. + +Run: mvn package -DskipTests -B +Expected: BUILD SUCCESS. diff --git a/docs/superpowers/plans/2026-08-04-local-token-sso-implementation.md b/docs/superpowers/plans/2026-08-04-local-token-sso-implementation.md new file mode 100644 index 0000000..1979886 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-local-token-sso-implementation.md @@ -0,0 +1,262 @@ +# Local Token SSO Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace database-coupled platform SSO with shared-secret JWT verification, local user/password synchronization, and direct system-JWT redirect. + +**Architecture:** The SSO boundary verifies a three-segment HS256 parent JWT with a configured shared secret and produces validated claims. A transactional local account service finds or creates `sys_user` by `userId`, synchronizes the profile, role, and BCrypt password hash, then the controller issues the existing system JWT and redirects it to the frontend. No runtime component connects to or queries a platform database. + +**Tech Stack:** Java 8, Spring Boot 2.7.18, Spring Security OAuth2 JOSE/Nimbus, MyBatis-Plus, H2 test profile, BCrypt. + +## Global Constraints + +- Never connect to, query, or synchronize from the main platform database. +- Verify the parent JWT with `platform-integration.token.link-secret-key`; never log or commit the shared secret, parent token, or password claim. +- Require parent claims `userId`, `username`, `password`, and `roleid`. +- Use `sys_user.id = userId`; map `roleid == 3` to `TEACHER`, all other values to `STUDENT`. +- Every valid SSO login overwrites the local password hash with BCrypt of the parent `password` claim. +- The application has one local datasource and `spring.datasource.hikari.minimum-idle: 2`. + +--- + +## Task 1: Verify the parent Token without a platform repository + +**Files:** +- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java` +- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java` +- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java` +- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java` +- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java` +- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java` +- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java` +- Test: `src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java` +- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java` +- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java` + +**Interfaces:** +- Produces `VerifiedPlatformToken verify(String rawToken)` with `userId()`, `username()`, `displayName()`, `rawPassword()`, `roleKey()`, and `optionalClaims()`. + +- [ ] **Step 1: Write failing verifier tests.** + +```java +@Test +void acceptsSignedParentTokenWithoutDatabaseLookup() { + VerifiedPlatformToken token = verifier.verify(parentToken(claims( + "userId", 487L, "username", "tzs001", "password", "new-password", "roleid", 2L))); + assertThat(token.userId()).isEqualTo(487L); + assertThat(token.username()).isEqualTo("tzs001"); + assertThat(token.rawPassword()).isEqualTo("new-password"); + assertThat(token.roleKey()).isEqualTo("STUDENT"); +} + +@Test +void rejectsTamperedExpiredOrIncompleteToken() { + assertThatThrownBy(() -> verifier.verify(tamperedToken)).isInstanceOf(PlatformTokenException.class); + assertThatThrownBy(() -> verifier.verify(expiredToken)).isInstanceOf(PlatformTokenException.class); + assertThatThrownBy(() -> verifier.verify(parentToken(claims("userId", 487L)))) + .isInstanceOf(PlatformTokenException.class); +} +``` + +- [ ] **Step 2: Run the verifier test to prove the old four-segment, repository-backed verifier fails.** + +Run: `mvn -B -Dtest=PlatformTokenVerifierTest test` + +Expected: FAIL because the old verifier requires `PlatformIdentityRepository`. + +- [ ] **Step 3: Implement the minimal shared-secret verifier.** + +Use the already available Spring Security JOSE support with a `SecretKeySpec(linkSecretKey.getBytes(UTF_8), "HmacSHA256")` to decode an HS256 JWT and validate its standard expiration. Reject blank or missing `userId`, `username`, `password`, or non-numeric `roleid`. Set `roleKey` to `TEACHER` only for `roleid == 3`; retain raw password only in-process and exclude it from logs, exceptions, and responses. Replace the old `Token` properties with: + +```java +public static class Token { + @NotBlank + private String linkSecretKey; +} +``` + +- [ ] **Step 4: Run the verifier tests and commit.** + +Run: `mvn -B -Dtest=PlatformTokenVerifierTest test` + +Expected: PASS with no repository mock or platform datasource fixture. + +```bash +git add src/main/java/com/yau/digitalrmb/platformintegration src/test/java/com/yau/digitalrmb/platformintegration +git commit -m "feat: verify platform jwt locally" +``` + +## Task 2: Synchronize only the local user, role, snapshot, and password + +**Files:** +- Create: `src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java` +- Delete: `src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java` +- Delete: `src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java` +- Test: `src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java` +- Delete: `src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java` + +**Interfaces:** +- Consumes `VerifiedPlatformToken`, `UserMapper`, `PlatformUserSnapshotMapper`, `PasswordEncoder`, and primary-datasource JDBC access. +- Produces `LocalSsoAccount synchronize(VerifiedPlatformToken token)` with `userId()`, `username()`, `displayName()`, and `roleKey()`. + +- [ ] **Step 1: Write failing account synchronization tests.** + +```java +@Test +void createsUserRoleSnapshotAndBcryptPassword() { + LocalSsoAccount account = service.synchronize(studentToken(487L, "tzs001", "first-password")); + assertThat(account.userId()).isEqualTo(487L); + assertThat(passwordEncoder.matches("first-password", userMapper.selectById(487L).getPasswordHash())).isTrue(); + assertThat(roleIdsFor(487L)).containsExactly(1002L); +} + +@Test +void refreshesPasswordAndRoleForExistingUser() { + service.synchronize(studentToken(487L, "tzs001", "old-password")); + service.synchronize(teacherToken(487L, "tzs002", "new-password")); + assertThat(passwordEncoder.matches("new-password", userMapper.selectById(487L).getPasswordHash())).isTrue(); + assertThat(passwordEncoder.matches("old-password", userMapper.selectById(487L).getPasswordHash())).isFalse(); + assertThat(roleIdsFor(487L)).containsExactly(1001L); +} +``` + +- [ ] **Step 2: Run the new test to prove it fails.** + +Run: `mvn -B -Dtest=LocalSsoAccountServiceTest test` + +Expected: FAIL because `LocalSsoAccountService` does not exist. + +- [ ] **Step 3: Implement the transactional local synchronizer.** + +Within one `@Transactional` method, load `sys_user` by `token.userId()`, create it with that ID when absent, and otherwise update it. In both branches set username, enabled status, and `passwordHash = passwordEncoder.encode(token.rawPassword())`. Upsert `platform_user_snapshot` from non-sensitive claims. Delete that user's `sys_user_role` rows and insert role ID `1001` for teachers or `1002` for students. Do not inject a named or secondary datasource. + +- [ ] **Step 4: Run the test and commit.** + +Run: `mvn -B -Dtest=LocalSsoAccountServiceTest test` + +Expected: PASS; repeated SSO has one user, one role row, and the current BCrypt password. + +```bash +git add src/main/java/com/yau/digitalrmb/identity src/test/java/com/yau/digitalrmb/identity +git commit -m "feat: sync local account from platform token" +``` + +## Task 3: Redirect the system JWT and remove exchange-code login + +**Files:** +- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java` +- Modify: `src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java` +- Delete: `src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java` +- Delete: `src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java` +- Delete: `src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java` +- Test: `src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java` +- Modify: `src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java` +- Delete: `src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java` + +**Interfaces:** +- Consumes `PlatformTokenVerifier.verify`, `LocalSsoAccountService.synchronize`, and `JwtTokenService.issueFor(long, String, Set)`. +- Produces `GET /api/v1/auth/sso?token=...` with 302 `Location: {frontendCallbackUrl}?token={urlEncodedSystemJwt}`. + +- [ ] **Step 1: Write failing MVC tests.** + +```java +mockMvc.perform(get("/api/v1/auth/sso").param("token", validParentToken)) + .andExpect(status().isFound()) + .andExpect(header().string("Location", startsWith("https://rmb.example.edu/sso-callback?token="))) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(header().string("Referrer-Policy", "no-referrer")); + +mockMvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"tzs001\",\"password\":\"new-password\"}")) + .andExpect(status().isOk()); +``` + +- [ ] **Step 2: Run controller tests to prove the old exchange-code redirect fails.** + +Run: `mvn -B -Dtest=PlatformSsoControllerTest,AuthControllerTest test` + +Expected: FAIL because the old `Location` has `code=`. + +- [ ] **Step 3: Implement direct SSO redirect.** + +Verify the parent Token, synchronize the local account, issue the existing system JWT for the local ID and one role, URL-encode that system JWT, and redirect to `frontend.callback-url` with query key `token`. Preserve `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. Remove `/session/exchange`; preserve `/login`, `/me`, and `/logout`. + +- [ ] **Step 4: Run controller tests and commit.** + +Run: `mvn -B -Dtest=PlatformSsoControllerTest,AuthControllerTest test` + +Expected: PASS; neither the incoming Token nor its password appears in the redirect. + +```bash +git add src/main/java/com/yau/digitalrmb/platformintegration/interfaces src/main/java/com/yau/digitalrmb/security src/test/java/com/yau/digitalrmb/platformintegration/interfaces src/test/java/com/yau/digitalrmb/security +git commit -m "feat: redirect local jwt after sso" +``` + +## Task 4: Remove the second datasource and verify Java 8 startup + +**Files:** +- Modify: `src/main/resources/application.yml` +- Modify: `src/main/resources/application-dev.yml` +- Modify: `src/main/resources/application-local.yml` +- Modify: `src/main/resources/application-test.yml` +- Modify: `src/main/resources/schema.sql` +- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java` +- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java` +- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java` +- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java` +- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java` +- Modify: `src/test/java/com/yau/digitalrmb/ApplicationContextTest.java` +- Modify: `README.md` + +**Interfaces:** +- Produces an application context with primary `dataSource` only; no `platformReadOnlyDataSource` or `platformNamedParameterJdbcTemplate` bean. + +- [ ] **Step 1: Write the failing one-datasource context test.** + +```java +@Autowired ApplicationContext context; + +@Test +void startsWithOnlyLocalDatasource() { + assertThat(context.containsBean("dataSource")).isTrue(); + assertThat(context.containsBean("platformReadOnlyDataSource")).isFalse(); + assertThat(context.containsBean("platformNamedParameterJdbcTemplate")).isFalse(); +} +``` + +- [ ] **Step 2: Run it and verify it fails while the platform datasource exists.** + +Run: `mvn -B -Dtest=ApplicationContextTest test` + +Expected: FAIL because `platformReadOnlyDataSource` exists. + +- [ ] **Step 3: Remove platform database, CAS, and sync configuration.** + +Delete secondary datasource/CAS/sync properties from all profiles and remove their classes/tests. Retain `platform_user_snapshot` and `auth_login_exchange_code` tables to avoid destructive database migration, but remove executable references to exchange codes. Configure only `platform-integration.token.link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY}` and `platform-integration.frontend.callback-url`; set the primary Hikari `minimum-idle` to `2`. + +- [ ] **Step 4: Run context/full Java 8 tests and build.** + +Run: `mvn -B -Dtest=ApplicationContextTest test` + +Expected: PASS; only `dataSource` is built. + +Run: `mvn -B test` + +Expected: PASS under Java 8. + +Run: `mvn -B -DskipTests package` + +Expected: BUILD SUCCESS with Java 8. + +- [ ] **Step 5: Update documentation, verify removed references, and commit.** + +Document `DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY`, `DIGITAL_RMB_FRONTEND_CALLBACK_URL`, required claims, direct redirect, and password refresh. Never include a real secret or password. + +Run: `rg -n "DIGITAL_RMB_PLATFORM_DB|platformReadOnlyDataSource|platformNamedParameterJdbcTemplate|LoginExchangeCodeService|/session/exchange" src README.md` + +Expected: no executable source reference. + +```bash +git add src/main/java src/main/resources src/test/java README.md +git commit -m "refactor: remove platform database dependency" +``` diff --git a/docs/superpowers/specs/2026-08-03-issuance-request-design.md b/docs/superpowers/specs/2026-08-03-issuance-request-design.md new file mode 100644 index 0000000..0fa509e --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-issuance-request-design.md @@ -0,0 +1,126 @@ +# 数字货币发行请求模块设计 + +## 目标 + +实现“①发送数字货币发行请求”教学页面的后端。学生携带有效的本系统 JWT 后,可以在商业银行端查询库存、创建并逐步处理发行申请,并在中央银行端查看接收结果。接口不区分教师与学生角色。 + +## 范围 + +本期仅覆盖页面的第 1 步:商业银行生成并发送发行请求,以及中央银行显示已接收的请求。两个端是独立的接口入口和查询视图,但属于同一教学系统并共享发行申请数据。第 2 至第 7 步(验签、业务核查、准备金扣减、数字货币生成、确权)不在本期实现范围内。 + +## 限界上下文与分层 + +扩展既有 `issuance` 限界上下文,不新增顶层模块。`IssuanceRequest` 是聚合根;库存查询是该上下文的只读查询模型。 + +```text +issuance/ + domain/ + model/ IssuanceRequest、IssuanceApplicationId、DenominationItem、IssuanceRequestStatus + repository/ IssuanceRequestRepository + service/ IssuanceMessageComposer、IssuanceSignatureService + application/ + command/ CreateIssuanceRequestCommand、UpdateDenominationsCommand + query/ IssuanceInventoryQueryService、IssuanceRequestQueryService + service/ IssuanceRequestApplicationService + infrastructure/ + persistence/ MyBatis-Plus Entity、Mapper、Repository 实现 + crypto/ Bouncy Castle 的 SM3/SM2 实验实现 + interfaces/ + rest/ CommercialBankIssuanceController、CentralBankIssuanceController + dto/ 请求与响应 DTO +``` + +依赖方向保持 `interfaces → application → domain`,`infrastructure` 只实现领域端口。Controller 不直接调用 Mapper。 + +## 聚合与状态机 + +`IssuanceRequest` 保存机构代码、机构标识、发行金额、面额明细、币种、时间戳、待签名原文、摘要、签名、签名密钥标识、请求报文和当前状态。 + +商业银行申请状态只能按下列顺序变化: + +```text +DRAFT → MESSAGE_PREPARED → DIGESTED → SIGNED → PACKAGED → SENT +``` + +中央银行接收状态独立保存: + +```text +NOT_RECEIVED → RECEIVED +``` + +- 只有 `DRAFT` 可修改总金额、面额明细和币种。 +- 面额数量必须为正整数,`Σ(面额 × 数量)` 必须等于发行总金额。 +- `prepare-message` 只允许从 `DRAFT` 执行,并冻结时间戳和待签名原文。 +- `digest`、`sign`、`package` 只允许在前一状态执行。 +- `send` 从 `PACKAGED` 进入 `SENT` 后,在同一事务中将中央银行接收状态设为 `RECEIVED` 并记录接收时间,模拟中央银行已接收;重复发送返回同一请求,不生成第二笔记录。 +- 已到达目标状态的重复按钮请求返回当前数据,不重新计算摘要或签名。 + +待签名原文采用固定字段顺序: + +```text +ISSUE|{bankCode}|{organizationId}|{totalAmount}|{denominations}|{currency}|{timestamp} +``` + +`denominations` 按面额从大到小序列化为 `面额:数量`,例如 `100:400,50:100,20:200,10:50,5:80,1:100`。 + +## 密码学教学实现 + +服务端使用 Bouncy Castle 提供的 SM3 和 SM2 算法:摘要为 SM3 十六进制文本,签名算法为 `SM3withSM2`。私钥不通过接口输入或返回。 + +本期通过 `SigningKeyProvider` 端口提供 `sm2-key-02` 实验密钥;基础设施实现仅在服务端生成并缓存密钥对。响应只暴露 `signingKeyRef`、摘要和签名。后续第 2 步验签可替换为配置化或密钥库实现,不改变聚合和接口契约。 + +## 数据模型 + +基础初始化脚本 `src/main/resources/schema.sql` 增加以下幂等表和演示数据: + +| 表 | 关键字段 | 用途 | +| --- | --- | --- | +| `issuance_bank_inventory` | `bank_code`、`current_balance`、`warning_threshold` | 查询库存、预警阈值和建议补充金额。预置 `BKCHCNBJ00001`。 | +| `issuance_request` | `id`、`request_no`、`bank_code`、`organization_id`、`total_amount`、`currency`、`request_timestamp`、`message_text`、`digest`、`signature`、`signing_key_ref`、`payload_json`、`status`、`central_receive_status`、`central_received_at`、审计字段 | 发行申请聚合持久化。 | +| `issuance_request_denomination` | `request_id`、`denomination`、`quantity` | 发行请求的面额明细,`request_id + denomination` 唯一。 | + +建议补充金额始终计算为 `max(warning_threshold - current_balance, 0)`,不单独持久化。 + +## REST 接口 + +所有接口要求有效 JWT,不添加角色限定。商业银行端与中央银行端使用不同的 URL 前缀和 Controller;中央银行端不提供创建、修改、签名或发送操作。 + +| 方法与路径 | 作用 | +| --- | --- | +| `GET /api/v1/commercial-banks/issuance/inventory?bankCode=BKCHCNBJ00001` | 商业银行端返回库存余额、预警阈值、建议补充金额。 | +| `POST /api/v1/commercial-banks/issuance/requests` | 商业银行端创建 `DRAFT` 发行申请。 | +| `PUT /api/v1/commercial-banks/issuance/requests/{id}` | 商业银行端仅在 `DRAFT` 更新金额、面额明细和币种。 | +| `GET /api/v1/commercial-banks/issuance/requests/{id}` | 商业银行端返回完整申请与处理产物。 | +| `POST /api/v1/commercial-banks/issuance/requests/{id}/prepare-message` | 商业银行端冻结时间戳并生成待签名原文。 | +| `POST /api/v1/commercial-banks/issuance/requests/{id}/digest` | 商业银行端对待签名原文生成 SM3 摘要。 | +| `POST /api/v1/commercial-banks/issuance/requests/{id}/sign` | 商业银行端使用 `sm2-key-02` 生成 SM2 签名。 | +| `POST /api/v1/commercial-banks/issuance/requests/{id}/package` | 商业银行端生成请求 JSON 报文。 | +| `POST /api/v1/commercial-banks/issuance/requests/{id}/send` | 商业银行端模拟发送;申请变为 `SENT`,中央银行接收状态变为 `RECEIVED`。 | +| `GET /api/v1/central-banks/issuance/requests/{id}` | 中央银行端返回接收状态、接收时间和 JSON 报文。 | + +不存在的申请返回 `RESOURCE_NOT_FOUND`;状态不合法或金额校验不通过返回 `VALIDATION_ERROR`;未认证请求沿用既有 Spring Security 的 `UNAUTHORIZED` 响应。 + +## Swagger 文档 + +Swagger 页面使用中文,所有新增 Java 源文件和 OpenAPI 元数据以 UTF-8 保存,页面不允许出现乱码。发行域接口按以下中文标签分组: + +- `数字货币发行模块 - 商业银行端`:库存查询、创建申请、更新申请、生成原文、摘要、签名、封装和发送。 +- `数字货币发行模块 - 中央银行端`:查询发行请求接收状态、接收时间和请求报文。 + +每个接口通过中文 `summary` 和 `description` 明确说明所属模块、所属端、调用条件、状态变化及返回内容;请求字段和响应字段使用中文 `@Schema` 描述。OpenAPI 根标题固定为“数字人民币教学仿真后端”,接口分组和接口说明不得使用英文替代中文。 + +## 测试标准 + +- 领域单元测试覆盖金额守恒、非法状态流转和重复发送幂等性。 +- 应用/持久化测试覆盖库存建议金额、请求与面额明细的保存和读取。 +- `MockMvc` 测试覆盖学生有效 JWT 下的所有首期接口,以及未认证被拒绝。 +- 密码学测试校验 SM3 输出长度和 SM2 签名可由同一实验公钥验证。 +- 全量 Maven 测试在 JDK 8 下通过;以 `dev` profile 启动后,Swagger 页面可访问。 +- 访问 `/v3/api-docs` 和 Swagger UI,确认“数字货币发行模块 - 商业银行端”“数字货币发行模块 - 中央银行端”标签及中文说明可正常显示,不含乱码字符。 + +## 明确约束 + +- 使用 JDK 8、Spring Boot 2.7.18、MyBatis-Plus 3.5.17、Spring Security 5.7。 +- 不恢复 Flyway;所有基础表变化同步更新幂等 `schema.sql` 并在 118 测试库验证。 +- 不在日志、接口响应或数据库中保存私钥明文。 +- 不实现真实央行网络调用、准备金扣减或数字货币生成。 diff --git a/docs/superpowers/specs/2026-08-04-current-user-context-design.md b/docs/superpowers/specs/2026-08-04-current-user-context-design.md new file mode 100644 index 0000000..6c34ccb --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-current-user-context-design.md @@ -0,0 +1,72 @@ +# 当前用户上下文设计 + +## 目标 + +为后端业务代码提供统一的当前用户读取能力,并将 `GET /api/v1/auth/me` 扩展为返回主平台 Token 中经验证的完整用户资料。 + +## 范围 + +- SSO 成功后,从已验证的主平台 Token payload 提取并本地持久化用户资料。 +- 提供可注入的当前用户读取服务,供任意已认证业务接口使用。 +- 扩展 `/api/v1/auth/me` 返回的 DTO。 +- 不改变本系统 JWT 的主体语义、角色授权方式或本地账号密码登录流程。 + +## 用户资料契约 + +当前用户对象和 `/api/v1/auth/me` 的 `data` 使用下列 camelCase 字段。`roleid` 与 `studentid` 保持主平台字段拼写,以避免调用方转换。 + +| 字段 | 来源 | 说明 | +| --- | --- | --- | +| `schoolId` / `schoolName` | Token `schoolId` / `schoolName` | 学校标识与名称 | +| `collegeId` / `collegeName` | Token `collegeId` / `collegeName` | 院系标识与名称 | +| `majorId` / `majorName` | Token `majorId` / `majorName` | 专业标识与名称 | +| `roleid` | Token `roleid` | 主平台原始角色 ID;不替换为本系统角色 ID | +| `userId` | Token `userId` | 主平台用户 ID,也是本地用户 ID 与 JWT subject | +| `username` | Token `username` | 用户登录账号 | +| `name` | Token `name` | 用户姓名 | +| `classId` / `className` | Token `classId` / `className` | 班级标识与名称 | +| `studentid` | Token `studentid` | 学号/学生标识 | + +标识类字段以字符串持久化和返回,以兼容主平台可能出现的非纯数字编码;`userId` 是唯一的数值主键,解析为 `long`。 + +## 架构与数据流 + +```text +主平台 Token + -> PlatformTokenVerifier(验签、校验必填资料) + -> PlatformActor(完整的已验证身份) + -> PlatformIdentityProjectionService(事务内更新 sys_user、快照、授权角色) + -> platform_user_snapshot + +本系统 JWT subject(userId) + -> CurrentUserService + -> platform_user_snapshot + -> 业务代码 / GET /api/v1/auth/me +``` + +`PlatformTokenVerifier` 只能在签名、登录时间和现有身份声明均通过后,才构造含完整资料的 `PlatformActor`。它校验 `userId`、`username`、`name`、`roleid` 必填,并将 `roleid` 映射为既有 `TEACHER` 或 `STUDENT` 以保留 Spring Security 授权行为。学校、院系、专业、班级和 `studentid` 允许为空,以兼容教师等不具备学生组织信息的身份。 + +`PlatformIdentityProjectionService` 在现有事务中更新扩展后的 `platform_user_snapshot`。快照是当前用户资料的唯一读取源;业务请求不会重新解析主平台 Token,也不会查询主平台数据库。 + +`CurrentUserService` 位于 `security.application`,从 Spring Security 的 `Jwt` 读取 subject,查询快照并返回不可变当前用户对象。未认证、subject 不是正整数或快照不存在时抛出既有 `UNAUTHORIZED` 业务异常。Controller 只调用该服务并将结果转换/直接作为响应 DTO 返回,不直接访问 Mapper。 + +## 持久化 + +扩展 `platform_user_snapshot`,增加学校、院系、专业、班级、主平台原始角色 ID 和学生标识字段。`schema.sql` 需同时覆盖新环境建表和已有数据库的幂等升级;不引入 Flyway。快照中保留 `role_key` 作为本系统授权映射,新增的 `roleid` 独立保存主平台原始值。 + +## API 与错误处理 + +`GET /api/v1/auth/me` 仍要求本系统 Bearer JWT,并返回既有 `ApiResponse` 包装。成功响应只包含上述用户资料字段,不包含密码、主平台 Token、刷新令牌或快照同步时间。未认证或快照缺失时维持现有未授权错误语义。 + +## 测试 + +- Token 验证测试:完整资料被解析进已验证身份;缺少必填字段的 Token 被拒绝。 +- 身份投影测试:扩展资料写入并在重复 SSO 时更新,不产生重复角色关联。 +- 当前用户服务/接口测试:持有本系统 JWT 时能读取全部字段;无效 subject 与不存在快照返回未授权。 +- 运行项目规定的 Maven 测试与打包命令。 + +## 非目标 + +- 不向业务 Controller 暴露用户、角色或快照 CRUD。 +- 不在每次业务请求时调用主平台或解析主平台 Token。 +- 不在本系统 JWT 中复制完整用户资料。 diff --git a/docs/superpowers/specs/2026-08-04-local-token-sso-design.md b/docs/superpowers/specs/2026-08-04-local-token-sso-design.md new file mode 100644 index 0000000..1fe8f53 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-local-token-sso-design.md @@ -0,0 +1,57 @@ +# 本地 Token SSO 设计 + +## 目标 + +数字人民币后端不连接、不读取主平台数据库。主平台通过跳转链接携带 JWT;后端使用双方约定的共享密钥验证该 JWT,并仅使用 Token 中的用户数据创建或更新本地用户,然后直接签发本系统 JWT 并重定向给前端。 + +本地账号密码登录必须保留。主平台 Token 中携带的密码是本地密码的同步来源:每次 SSO 登录成功后,后端都将该密码 BCrypt 编码后写入本地用户表。因此,用户在主平台修改密码并再次通过 SSO 跳转后,可立即使用新密码独立登录本系统。 + +## 边界与依赖 + +保留的外部依赖只有主平台 Token 的共享密钥;不保留任何主平台数据源、数据库表查询、同步任务或远程身份仓储。 + +主平台 Token 采用 PEVC 示例相同的标准三段 JWT 与 HMAC 共享密钥验签。运行时通过环境变量配置共享密钥,禁止将其提交到代码库。 + +必需 claim: + +- `userId`:主平台用户 ID,也是本地 `sys_user.id`。 +- `username`:本地登录账号。 +- `password`:主平台当前明文密码,仅用于立即 BCrypt 编码后保存,不记录到日志、快照或响应。 +- `roleid`:`3` 映射为 `TEACHER`,其他值映射为 `STUDENT`。 + +可选 claim:`name`、`schoolId`、`schoolName`、`classId`、`className`、`collegeId`、`collegeName`、`studentNo`、`realName`。这些字段仅在本地快照或本系统 JWT 扩展字段中使用。 + +## SSO 流程 + +1. 前端或主平台访问 `GET /api/v1/auth/sso?token={parentToken}`。 +2. 后端使用共享密钥验证 JWT 的签名与有效期,并提取必需 claim。 +3. 后端以 `userId` 查询本地 `sys_user`。 +4. 若不存在,使用 Token 数据创建本地用户、用户快照和 `sys_user_role`;若存在,更新账号、启用状态、快照、角色关联及 BCrypt 密码散列。 +5. 后端以本地用户信息签发数字人民币系统 JWT,并以 302 重定向至 `{frontend-callback-url}/sso-callback?token={digitalRmbToken}`。 + +本系统 JWT 的 subject 为本地 `userId`,并包含 `preferred_username` 与本地角色列表。SSO 入口不再生成或消费一次性交换码。 + +## 数据一致性与错误处理 + +用户、快照、角色关联和密码散列更新在同一个本地数据库事务内完成。已验证 Token 的用户同步必须幂等:同一用户多次跳转不会产生重复用户或角色行。 + +签名无效、过期、缺失必需 claim、非法 `userId` 或空账号/密码时,拒绝登录并重定向至前端登录页的 SSO 失败状态。不得在响应、日志或异常中输出原始 Token 或密码。 + +## 删除项 + +删除以下主平台数据库依赖: + +- `platformReadOnlyDataSource` 与 `platformNamedParameterJdbcTemplate`。 +- `PlatformIdentityRepository` 的远程实现及相关主平台查询 SQL。 +- `PlatformIdentitySyncJob` 及 `platform-integration.sync` 配置。 +- 依赖远程身份数据验签的旧四段 Token 校验逻辑。 + +删除一次性交换码的 SSO 路径及其服务调用。为避免破坏现有数据库数据,既有 `auth_login_exchange_code` 表可暂时保留但不再被应用访问。 + +## 验证标准 + +- 应用启动时只创建一个本地业务数据源。 +- 有效主平台 JWT 可创建本地用户并直接重定向携带本系统 JWT。 +- 已存在用户每次 SSO 后密码散列都会更新;使用 Token 中的新密码可通过本地 `/api/v1/auth/login` 登录。 +- 无效签名、过期 Token 或缺失必需字段不能创建/更新本地用户。 +- 本地账号密码登录、当前用户查询和注销的既有行为保持可用。 diff --git a/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java b/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java new file mode 100644 index 0000000..f47ba21 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java @@ -0,0 +1,80 @@ +package com.yau.digitalrmb.identity.application; + +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity; +import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper; +import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.UserMapper; +import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +public class LocalSsoAccountService { + private final UserMapper userMapper; + private final PlatformUserSnapshotMapper snapshotMapper; + private final JdbcTemplate jdbcTemplate; + private final PasswordEncoder passwordEncoder; + + public LocalSsoAccountService(UserMapper userMapper, PlatformUserSnapshotMapper snapshotMapper, + JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) { + this.userMapper = userMapper; + this.snapshotMapper = snapshotMapper; + this.jdbcTemplate = jdbcTemplate; + this.passwordEncoder = passwordEncoder; + } + + @Transactional + public void synchronize(VerifiedPlatformToken token) { + UserEntity user = userMapper.selectById(token.getUserId()); + boolean newUser = user == null; + if (user == null) { + user = new UserEntity(); + user.setId(token.getUserId()); + } + user.setUsername(token.getUsername()); + user.setPasswordHash(passwordEncoder.encode(token.getRawPassword())); + user.setEnabled(true); + if (newUser) { + userMapper.insert(user); + } else { + userMapper.updateById(user); + } + upsertSnapshot(token); + jdbcTemplate.update("DELETE FROM sys_user_role WHERE user_id = ?", token.getUserId()); + jdbcTemplate.update("INSERT INTO sys_user_role (user_id, role_id) VALUES (?, ?)", + token.getUserId(), "TEACHER".equals(token.getRoleKey()) ? 1001L : 1002L); + } + + private void upsertSnapshot(VerifiedPlatformToken token) { + PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(token.getUserId()); + boolean newSnapshot = snapshot == null; + if (snapshot == null) { + snapshot = new PlatformUserSnapshotEntity(); + snapshot.setPlatformUserId(token.getUserId()); + } + snapshot.setAccount(token.getUsername()); + snapshot.setDisplayName(token.getDisplayName()); + snapshot.setRoleKey(token.getRoleKey()); + snapshot.setSchoolId(token.getSchoolId()); + snapshot.setSchoolName(token.getSchoolName()); + snapshot.setCollegeId(token.getCollegeId()); + snapshot.setCollegeName(token.getCollegeName()); + snapshot.setMajorId(token.getMajorId()); + snapshot.setMajorName(token.getMajorName()); + snapshot.setRoleId(token.getRoleId()); + snapshot.setClassId(token.getClassId()); + snapshot.setClassName(token.getClassName()); + snapshot.setStudentId(token.getStudentId()); + snapshot.setSourceUpdatedAt(LocalDateTime.now()); + snapshot.setSyncedAt(LocalDateTime.now()); + if (newSnapshot) { + snapshotMapper.insert(snapshot); + } else { + snapshotMapper.updateById(snapshot); + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java b/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java deleted file mode 100644 index 6a619fd..0000000 --- a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.yau.digitalrmb.identity.application; - -import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; -import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity; -import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper; -import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.UserMapper; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.UUID; - -@Service -public class PlatformIdentityProjectionService { - private final UserMapper userMapper; - private final PlatformUserSnapshotMapper snapshotMapper; - private final JdbcTemplate jdbcTemplate; - private final PasswordEncoder passwordEncoder; - - public PlatformIdentityProjectionService(UserMapper userMapper, - PlatformUserSnapshotMapper snapshotMapper, - JdbcTemplate jdbcTemplate, - PasswordEncoder passwordEncoder) { - this.userMapper = userMapper; - this.snapshotMapper = snapshotMapper; - this.jdbcTemplate = jdbcTemplate; - this.passwordEncoder = passwordEncoder; - } - - @Transactional - public void project(PlatformActor actor) { - projectUser(actor); - projectSnapshot(actor); - projectRole(actor); - } - - private void projectUser(PlatformActor actor) { - UserEntity user = userMapper.selectById(actor.platformUserId()); - if (user == null) { - user = new UserEntity(); - user.setId(actor.platformUserId()); - user.setUsername(actor.account()); - user.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString())); - user.setEnabled(true); - userMapper.insert(user); - return; - } - user.setUsername(actor.account()); - user.setEnabled(true); - userMapper.updateById(user); - } - - private void projectSnapshot(PlatformActor actor) { - PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(actor.platformUserId()); - boolean newSnapshot = snapshot == null; - if (newSnapshot) { - snapshot = new PlatformUserSnapshotEntity(); - snapshot.setPlatformUserId(actor.platformUserId()); - } - snapshot.setAccount(actor.account()); - snapshot.setDisplayName(actor.displayName()); - snapshot.setRoleKey(actor.role().name()); - snapshot.setSourceUpdatedAt(LocalDateTime.ofInstant(actor.tokenSigningTime(), ZoneOffset.UTC)); - snapshot.setSyncedAt(LocalDateTime.now(ZoneOffset.UTC)); - if (newSnapshot) { - snapshotMapper.insert(snapshot); - } else { - snapshotMapper.updateById(snapshot); - } - } - - private void projectRole(PlatformActor actor) { - jdbcTemplate.update("DELETE FROM sys_user_role WHERE user_id = ?", actor.platformUserId()); - jdbcTemplate.update("INSERT INTO sys_user_role (user_id, role_id) VALUES (?, ?)", - actor.platformUserId(), roleId(actor)); - } - - private long roleId(PlatformActor actor) { - return actor.role().name().equals("TEACHER") ? 1001L : 1002L; - } -} diff --git a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java b/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java deleted file mode 100644 index 0268c84..0000000 --- a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.yau.digitalrmb.identity.application; - -import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.time.Instant; - -@Component -@ConditionalOnProperty(prefix = "platform-integration.sync", name = "enabled", havingValue = "true") -public class PlatformIdentitySyncJob { - private final PlatformIdentityRepository identityRepository; - private final PlatformIdentityProjectionService projectionService; - private Instant watermark = Instant.EPOCH; - - public PlatformIdentitySyncJob(PlatformIdentityRepository identityRepository, - PlatformIdentityProjectionService projectionService) { - this.identityRepository = identityRepository; - this.projectionService = projectionService; - } - - @Scheduled(fixedDelayString = "${platform-integration.sync.fixed-delay:PT15M}") - public synchronized void sync() { - Instant nextWatermark = Instant.now(); - syncChangedSince(watermark); - watermark = nextWatermark; - } - - public void syncChangedSince(Instant since) { - identityRepository.findChangedSince(since).forEach(projectionService::project); - } -} diff --git a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java index abd8913..c320678 100644 --- a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java +++ b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java @@ -22,6 +22,36 @@ public class PlatformUserSnapshotEntity { private String roleKey; + @TableField("school_id") + private String schoolId; + + @TableField("school_name") + private String schoolName; + + @TableField("college_id") + private String collegeId; + + @TableField("college_name") + private String collegeName; + + @TableField("major_id") + private String majorId; + + @TableField("major_name") + private String majorName; + + @TableField("role_id") + private Long roleId; + + @TableField("class_id") + private String classId; + + @TableField("class_name") + private String className; + + @TableField("student_id") + private String studentId; + private LocalDateTime sourceUpdatedAt; @TableField("synced_at") diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java deleted file mode 100644 index 81bd2c1..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.yau.digitalrmb.platformintegration.application; - -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; - -import java.util.Optional; -import java.time.Instant; -import java.util.List; - -public interface PlatformIdentityRepository { - Optional findByPlatformUserId(long platformUserId); - - Optional findBySchoolAccount(String schoolAccount); - - List findChangedSince(Instant watermark); -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java index efbc87c..9c37225 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java @@ -3,7 +3,6 @@ package com.yau.digitalrmb.platformintegration.application; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -13,36 +12,22 @@ import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Clock; -import java.time.Duration; import java.time.Instant; import java.util.Base64; @Component @ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true") public class PlatformTokenVerifier { - private static final Duration CLOCK_SKEW = Duration.ofSeconds(30); - - private final PlatformIdentityRepository identityRepository; private final PlatformIntegrationProperties.Token properties; private final ObjectMapper objectMapper; private final Clock clock; @Autowired - public PlatformTokenVerifier(PlatformIdentityRepository identityRepository, - PlatformIntegrationProperties properties) { - this(identityRepository, properties.getToken(), new ObjectMapper(), Clock.systemUTC()); - } - - PlatformTokenVerifier(PlatformIdentityRepository identityRepository, - PlatformIntegrationProperties.Token properties) { - this(identityRepository, properties, new ObjectMapper(), Clock.systemUTC()); + public PlatformTokenVerifier(PlatformIntegrationProperties properties) { + this(properties.getToken(), new ObjectMapper(), Clock.systemUTC()); } - PlatformTokenVerifier(PlatformIdentityRepository identityRepository, - PlatformIntegrationProperties.Token properties, - ObjectMapper objectMapper, - Clock clock) { - this.identityRepository = identityRepository; + PlatformTokenVerifier(PlatformIntegrationProperties.Token properties, ObjectMapper objectMapper, Clock clock) { this.properties = properties; this.objectMapper = objectMapper; this.clock = clock; @@ -55,23 +40,32 @@ public class PlatformTokenVerifier { VerifiedPlatformToken verify(String rawToken, Instant now) { try { String[] parts = rawToken.split("\\.", -1); - if (parts.length != 4) { + if (parts.length != 3) { throw invalid(); } - verifyLoginTime(parts[3], now); - JsonNode header = readJson(parts[0]); if (!"HS256".equals(header.path("alg").asText())) { throw invalid(); } + verifySignature(parts); JsonNode payload = readJson(parts[1]); - long platformUserId = parseAudience(payload.path("aud")); - PlatformActor actor = identityRepository.findByPlatformUserId(platformUserId) - .orElseThrow(this::invalid); - verifyIdentityClaim(payload, actor); - verifySignature(parts, actor); - - return new VerifiedPlatformToken(actor, fingerprint(rawToken)); + if (!payload.path("exp").canConvertToLong() || Instant.ofEpochSecond(payload.path("exp").asLong()).compareTo(now) <= 0) { + throw invalid(); + } + long userId = requiredPositiveLong(payload, "userId"); + String username = requiredText(payload, "username"); + String password = requiredText(payload, "password"); + long roleId = requiredLong(payload, "roleid"); + String displayName = optionalText(payload, "name"); + if (displayName == null || displayName.trim().isEmpty()) { + displayName = username; + } + return new VerifiedPlatformToken(userId, username, displayName, password, roleId, + roleId == 3L ? "TEACHER" : "STUDENT", optionalText(payload, "schoolId"), + optionalText(payload, "schoolName"), optionalText(payload, "collegeId"), + optionalText(payload, "collegeName"), optionalText(payload, "majorId"), + optionalText(payload, "majorName"), optionalText(payload, "classId"), + optionalText(payload, "className"), optionalText(payload, "studentid")); } catch (PlatformTokenException exception) { throw exception; } catch (Exception exception) { @@ -79,75 +73,19 @@ public class PlatformTokenVerifier { } } - private void verifyLoginTime(String value, Instant now) { - if (!value.matches("\\d{13}")) { - throw invalid(); - } - Instant loginTime; - try { - loginTime = Instant.ofEpochMilli(Long.parseLong(value)); - } catch (NumberFormatException exception) { - throw invalid(); - } - if (loginTime.isAfter(now.plus(CLOCK_SKEW)) - || loginTime.isBefore(now.minus(properties.getMaxAge()))) { - throw invalid(); - } - } - - private JsonNode readJson(String encoded) throws Exception { - return objectMapper.readTree(Base64.getUrlDecoder().decode(encoded)); - } - - private long parseAudience(JsonNode audience) { - JsonNode value = audience; - if (audience.isArray() && audience.size() == 1) { - value = audience.get(0); - } - if (!value.isTextual()) { - throw invalid(); - } - try { - return Long.parseLong(value.textValue()); - } catch (NumberFormatException exception) { - throw invalid(); - } - } - - private void verifyIdentityClaim(JsonNode payload, PlatformActor actor) { - String expected = actor.role().name().equals("TEACHER") - ? properties.getTeacherClaimValue() - : properties.getStudentClaimValue(); - if (!expected.equals(payload.path(String.valueOf(actor.profileId())).asText())) { - throw invalid(); - } - } - - private void verifySignature(String[] parts, PlatformActor actor) throws Exception { + private void verifySignature(String[] parts) throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); - byte[] key = String.valueOf(actor.tokenSigningTime().toEpochMilli()).getBytes(StandardCharsets.UTF_8); - mac.init(new SecretKeySpec(key, "HmacSHA256")); + mac.init(new SecretKeySpec(properties.getLinkSecretKey().getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] expected = mac.doFinal((parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII)); - byte[] actual = Base64.getUrlDecoder().decode(parts[2]); - if (!MessageDigest.isEqual(expected, actual)) { + if (!MessageDigest.isEqual(expected, Base64.getUrlDecoder().decode(parts[2]))) { throw invalid(); } } - private String fingerprint(String rawToken) throws Exception { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(rawToken.getBytes(StandardCharsets.UTF_8)); - StringBuilder fingerprint = new StringBuilder(digest.length * 2); - for (byte value : digest) { - String hex = Integer.toHexString(value & 0xff); - if (hex.length() == 1) { - fingerprint.append('0'); - } - fingerprint.append(hex); - } - return fingerprint.toString(); - } - - private PlatformTokenException invalid() { - return new PlatformTokenException("Invalid platform token"); - } + private JsonNode readJson(String encoded) throws Exception { return objectMapper.readTree(Base64.getUrlDecoder().decode(encoded)); } + private long requiredPositiveLong(JsonNode payload, String name) { long value = requiredLong(payload, name); if (value <= 0) throw invalid(); return value; } + private long requiredLong(JsonNode payload, String name) { JsonNode node = payload.path(name); if (!node.canConvertToLong()) throw invalid(); return node.asLong(); } + private String requiredText(JsonNode payload, String name) { String value = payload.path(name).asText(); if (value == null || value.trim().isEmpty()) throw invalid(); return value; } + private String optionalText(JsonNode payload, String name) { JsonNode node = payload.path(name); return node.isMissingNode() || node.isNull() ? null : node.asText(); } + private PlatformTokenException invalid() { return new PlatformTokenException("Invalid platform token"); } } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java index b4aa863..2ec3fc8 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java @@ -1,13 +1,50 @@ package com.yau.digitalrmb.platformintegration.application; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import lombok.EqualsAndHashCode; import lombok.Getter; @Getter -@EqualsAndHashCode public class VerifiedPlatformToken { - private final PlatformActor actor; private final String fingerprint; - public VerifiedPlatformToken(PlatformActor actor, String fingerprint) { this.actor = actor; this.fingerprint = fingerprint; } - public PlatformActor actor() { return actor; } public String fingerprint() { return fingerprint; } + private final long userId; + private final String username; + private final String displayName; + private final String rawPassword; + private final long roleId; + private final String roleKey; + private final String schoolId; + private final String schoolName; + private final String collegeId; + private final String collegeName; + private final String majorId; + private final String majorName; + private final String classId; + private final String className; + private final String studentId; + + public VerifiedPlatformToken(long userId, String username, String displayName, String rawPassword, String roleKey) { + this(userId, username, displayName, rawPassword, "TEACHER".equals(roleKey) ? 3L : 2L, roleKey, + null, null, null, null, null, null, null, null, null); + } + + public VerifiedPlatformToken(long userId, String username, String displayName, String rawPassword, long roleId, + String roleKey, String schoolId, String schoolName, String collegeId, + String collegeName, String majorId, String majorName, String classId, + String className, String studentId) { + this.userId = userId; + this.username = username; + this.displayName = displayName; + this.rawPassword = rawPassword; + this.roleId = roleId; + this.roleKey = roleKey; + this.schoolId = schoolId; + this.schoolName = schoolName; + this.collegeId = collegeId; + this.collegeName = collegeName; + this.majorId = majorId; + this.majorName = majorName; + this.classId = classId; + this.className = className; + this.studentId = studentId; + } + + public String getName() { return displayName; } } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java b/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java index 7868a99..a40298f 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java @@ -1,68 +1,28 @@ package com.yau.digitalrmb.platformintegration.config; -import javax.validation.Valid; -import javax.validation.constraints.AssertTrue; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; -import java.time.Duration; +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; @Getter @Setter @Validated @ConfigurationProperties("platform-integration") public class PlatformIntegrationProperties { - @Valid - private Datasource datasource = new Datasource(); @Valid private Token token = new Token(); @Valid - private Cas cas = new Cas(); - @Valid private Frontend frontend = new Frontend(); - @Valid - private Sync sync = new Sync(); - - @Getter - @Setter - public static class Datasource { - @NotBlank - private String url; - @NotBlank - private String username; - @NotBlank - private String password; - } @Getter @Setter public static class Token { - @NotNull - private Duration maxAge; - @NotBlank - private String teacherClaimValue; @NotBlank - private String studentClaimValue; - - @AssertTrue(message = "token.max-age must be positive") - public boolean isMaxAgePositive() { - return maxAge != null && !maxAge.isNegative() && !maxAge.isZero(); - } - } - - @Getter - @Setter - public static class Cas { - @NotBlank - private String loginUrl; - @NotBlank - private String validateUrl; - @NotBlank - private String callbackUrl; + private String linkSecretKey; } @Getter @@ -71,13 +31,4 @@ public class PlatformIntegrationProperties { @NotBlank private String callbackUrl; } - - @Getter - @Setter - public static class Sync { - private boolean enabled = false; - @NotNull - private Duration fixedDelay = Duration.ofMinutes(15); - } - } diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java b/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java deleted file mode 100644 index ab3c9d2..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.yau.digitalrmb.platformintegration.domain; - -import lombok.EqualsAndHashCode; -import lombok.Getter; -import java.time.Instant; - -@Getter -@EqualsAndHashCode -public class PlatformActor { - private final long platformUserId; private final long profileId; private final String account; private final String displayName; private final PlatformRole role; private final Instant tokenSigningTime; - public PlatformActor(long platformUserId, long profileId, String account, String displayName, PlatformRole role, Instant tokenSigningTime) { this.platformUserId = platformUserId; this.profileId = profileId; this.account = account; this.displayName = displayName; this.role = role; this.tokenSigningTime = tokenSigningTime; } - public long platformUserId() { return platformUserId; } public long profileId() { return profileId; } public String account() { return account; } public String displayName() { return displayName; } public PlatformRole role() { return role; } public Instant tokenSigningTime() { return tokenSigningTime; } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java b/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java deleted file mode 100644 index 63bf6f8..0000000 --- a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.yau.digitalrmb.platformintegration.domain; - -public enum PlatformRole { - TEACHER("JT_S_02"), - STUDENT("JT_S_03"); - - private final String jobType; - - PlatformRole(String jobType) { - this.jobType = jobType; - } - - public static PlatformRole fromJobType(String jobType) { - for (PlatformRole role : values()) { - if (role.jobType.equals(jobType)) { - return role; - } - } - throw new IllegalArgumentException("Unsupported platform job type: " + jobType); - } - - public String jobType() { - return jobType; - } -} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java index 3879d14..196ed9d 100644 --- a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java +++ b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java @@ -1,11 +1,16 @@ package com.yau.digitalrmb.platformintegration.interfaces; -import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; +import com.yau.digitalrmb.identity.application.LocalSsoAccountService; import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier; import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; +<<<<<<< HEAD import com.yau.digitalrmb.security.application.LoginExchangeCodeService; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +======= +import com.yau.digitalrmb.security.application.JwtTokenService; +import io.swagger.v3.oas.annotations.Operation; +>>>>>>> origin/master import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -14,32 +19,34 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.util.UriComponentsBuilder; +import java.util.Collections; + @RestController @ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true") @RequestMapping("/api/v1/auth") public class PlatformSsoController { private final PlatformTokenVerifier tokenVerifier; - private final PlatformIdentityProjectionService projectionService; - private final LoginExchangeCodeService exchangeCodeService; + private final LocalSsoAccountService localSsoAccountService; + private final JwtTokenService jwtTokenService; private final PlatformIntegrationProperties.Frontend frontend; - public PlatformSsoController(PlatformTokenVerifier tokenVerifier, - PlatformIdentityProjectionService projectionService, - LoginExchangeCodeService exchangeCodeService, - PlatformIntegrationProperties properties) { + public PlatformSsoController(PlatformTokenVerifier tokenVerifier, LocalSsoAccountService localSsoAccountService, + JwtTokenService jwtTokenService, PlatformIntegrationProperties properties) { this.tokenVerifier = tokenVerifier; - this.projectionService = projectionService; - this.exchangeCodeService = exchangeCodeService; + this.localSsoAccountService = localSsoAccountService; + this.jwtTokenService = jwtTokenService; this.frontend = properties.getFrontend(); } @GetMapping("/sso") + @Operation(description = "单点登录鉴权") public ResponseEntity loginFromPlatform(@RequestParam("token") String token) { VerifiedPlatformToken verified = tokenVerifier.verify(token); - projectionService.project(verified.actor()); - String exchangeCode = exchangeCodeService.issue(verified.actor().platformUserId()); + localSsoAccountService.synchronize(verified); + JwtTokenService.Token localToken = jwtTokenService.issueFor(verified.getUserId(), verified.getUsername(), + Collections.singleton(verified.getRoleKey())); String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl()) - .queryParam("code", exchangeCode).build().encode().toUriString(); + .queryParam("token", localToken.accessToken()).build().encode().toUriString(); return ResponseEntity.status(302) .header(HttpHeaders.LOCATION, location) .header(HttpHeaders.CACHE_CONTROL, "no-store") diff --git a/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java b/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java new file mode 100644 index 0000000..2bd0e07 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java @@ -0,0 +1,22 @@ +package com.yau.digitalrmb.security.application; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class CurrentUser { + private final String schoolId; + private final String schoolName; + private final String collegeId; + private final String collegeName; + private final String majorId; + private final String majorName; + private final Long roleid; + private final long userId; + private final String username; + private final String name; + private final String classId; + private final String className; + private final String studentid; +} diff --git a/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java b/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java new file mode 100644 index 0000000..09f451f --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java @@ -0,0 +1,46 @@ +package com.yau.digitalrmb.security.application; + +import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; +import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Service; + +@Service +public class CurrentUserService { + private final PlatformUserSnapshotMapper snapshotMapper; + + public CurrentUserService(PlatformUserSnapshotMapper snapshotMapper) { + this.snapshotMapper = snapshotMapper; + } + + public CurrentUser getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof Jwt)) { + throw unauthorized("用户身份无效"); + } + PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(parseUserId((Jwt) authentication.getPrincipal())); + if (snapshot == null) { + throw unauthorized("用户身份不存在"); + } + return new CurrentUser(snapshot.getSchoolId(), snapshot.getSchoolName(), snapshot.getCollegeId(), + snapshot.getCollegeName(), snapshot.getMajorId(), snapshot.getMajorName(), snapshot.getRoleId(), + snapshot.getPlatformUserId(), snapshot.getAccount(), snapshot.getDisplayName(), snapshot.getClassId(), + snapshot.getClassName(), snapshot.getStudentId()); + } + + private long parseUserId(Jwt jwt) { + try { + return Long.parseLong(jwt.getSubject()); + } catch (NumberFormatException exception) { + throw unauthorized("用户身份无效"); + } + } + + private BusinessException unauthorized(String message) { + return new BusinessException(ErrorCode.UNAUTHORIZED, message); + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java b/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java deleted file mode 100644 index 4da3dbe..0000000 --- a/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.yau.digitalrmb.security.application; - -import com.yau.digitalrmb.security.config.SecurityProperties; -import com.yau.digitalrmb.shared.api.ErrorCode; -import com.yau.digitalrmb.shared.exception.BusinessException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.sql.Timestamp; -import java.time.Instant; -import java.util.Base64; -import java.util.List; - -@Service -public class LoginExchangeCodeService { - private static final SecureRandom RANDOM = new SecureRandom(); - - private final JdbcTemplate jdbcTemplate; - private final SecurityProperties properties; - - public LoginExchangeCodeService(JdbcTemplate jdbcTemplate, SecurityProperties properties) { - this.jdbcTemplate = jdbcTemplate; - this.properties = properties; - } - - public String issue(long platformUserId) { - String code = randomValue(); - jdbcTemplate.update("INSERT INTO auth_login_exchange_code (code_hash, platform_user_id, expires_at, consumed_at) VALUES (?, ?, ?, NULL)", - hash(code), platformUserId, Timestamp.from(Instant.now().plus(properties.getSession().getExchangeCodeTtl()))); - return code; - } - - public long exchange(String code) { - String hash = hash(code); - List platformUserIds = jdbcTemplate.query( - "SELECT platform_user_id FROM auth_login_exchange_code WHERE code_hash = ?", - (resultSet, rowNum) -> resultSet.getLong(1), hash); - if (platformUserIds.isEmpty()) { - throw invalidCode(); - } - int consumed = jdbcTemplate.update( - "UPDATE auth_login_exchange_code SET consumed_at = CURRENT_TIMESTAMP " - + "WHERE code_hash = ? AND consumed_at IS NULL AND expires_at > CURRENT_TIMESTAMP", hash); - if (consumed != 1) { - throw invalidCode(); - } - return platformUserIds.get(0); - } - - static String hash(String value) { - try { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder hash = new StringBuilder(digest.length * 2); - for (byte item : digest) { - String hex = Integer.toHexString(item & 0xff); - if (hex.length() == 1) { - hash.append('0'); - } - hash.append(hex); - } - return hash.toString(); - } catch (Exception exception) { - throw new IllegalStateException("SHA-256 is unavailable", exception); - } - } - - private static String randomValue() { - byte[] bytes = new byte[32]; - RANDOM.nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - } - - private BusinessException invalidCode() { - return new BusinessException(ErrorCode.UNAUTHORIZED, "登录兑换码无效或已过期"); - } -} diff --git a/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java index 8f0ab3d..5ca6871 100644 --- a/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java +++ b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java @@ -4,6 +4,7 @@ import com.yau.digitalrmb.security.config.SecurityProperties; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import java.security.MessageDigest; import java.security.SecureRandom; import java.sql.Timestamp; import java.time.Instant; @@ -26,7 +27,7 @@ public class RefreshTokenService { RANDOM.nextBytes(bytes); String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); jdbcTemplate.update("INSERT INTO auth_refresh_token (token_hash, platform_user_id, expires_at, revoked_at) VALUES (?, ?, ?, NULL)", - LoginExchangeCodeService.hash(token), platformUserId, + hash(token), platformUserId, Timestamp.from(Instant.now().plus(properties.getSession().getRefreshTokenTtl()))); return token; } @@ -34,6 +35,20 @@ public class RefreshTokenService { public void revokeForUser(String token, long platformUserId) { jdbcTemplate.update("UPDATE auth_refresh_token SET revoked_at = CURRENT_TIMESTAMP " + "WHERE token_hash = ? AND platform_user_id = ? AND revoked_at IS NULL", - LoginExchangeCodeService.hash(token), platformUserId); + hash(token), platformUserId); + } + + private static String hash(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte item : digest) { + result.append(String.format("%02x", item)); + } + return result.toString(); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } } } diff --git a/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java index 3df3699..99640ce 100644 --- a/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java +++ b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java @@ -51,7 +51,6 @@ public class SecurityConfig { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeRequests(authorize -> authorize .antMatchers("/actuator/health", "/api/v1/auth/login", "/api/v1/auth/sso", - "/api/v1/auth/cas/**", "/api/v1/auth/session/exchange", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") .permitAll() .anyRequest().authenticated()) diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java index 968b9ae..183cc62 100644 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java @@ -1,16 +1,16 @@ package com.yau.digitalrmb.security.interfaces; -import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity; -import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper; +import com.yau.digitalrmb.security.application.CurrentUser; +import com.yau.digitalrmb.security.application.CurrentUserService; import com.yau.digitalrmb.security.application.JwtTokenService; -import com.yau.digitalrmb.security.application.LoginExchangeCodeService; import com.yau.digitalrmb.security.application.LocalAccountAuthenticationService; import com.yau.digitalrmb.security.application.RefreshTokenService; import com.yau.digitalrmb.shared.api.ApiResponse; import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.shared.web.TraceIdFilter; -import javax.validation.Valid; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.MDC; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; @@ -20,72 +20,53 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; -import java.util.Set; -import java.util.Collections; +import javax.validation.Valid; @RestController @RequestMapping("/api/v1/auth") +@Tag(name = "用户登录及权限认证") public class AuthController { - private final JwtTokenService tokenService; - private final LoginExchangeCodeService exchangeCodeService; private final RefreshTokenService refreshTokenService; - private final PlatformUserSnapshotMapper snapshotMapper; + private final CurrentUserService currentUserService; private final LocalAccountAuthenticationService localAccountAuthenticationService; - public AuthController(JwtTokenService tokenService, LoginExchangeCodeService exchangeCodeService, - RefreshTokenService refreshTokenService, PlatformUserSnapshotMapper snapshotMapper, + public AuthController(RefreshTokenService refreshTokenService, CurrentUserService currentUserService, LocalAccountAuthenticationService localAccountAuthenticationService) { - this.tokenService = tokenService; - this.exchangeCodeService = exchangeCodeService; this.refreshTokenService = refreshTokenService; - this.snapshotMapper = snapshotMapper; + this.currentUserService = currentUserService; this.localAccountAuthenticationService = localAccountAuthenticationService; } @PostMapping("/login") + @Operation(description = "用户登录") public ApiResponse login(@Valid @RequestBody LoginRequest request) { JwtTokenService.Token token = localAccountAuthenticationService.login(request.username(), request.password()); return ApiResponse.success(new LoginResponse(token.accessToken(), "Bearer", token.expiresIn()), MDC.get(TraceIdFilter.MDC_KEY)); } - @PostMapping("/session/exchange") - public ApiResponse exchange(@Valid @RequestBody ExchangeCodeRequest request) { - long platformUserId = exchangeCodeService.exchange(request.code()); - PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(platformUserId); - if (snapshot == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在"); - } - JwtTokenService.Token accessToken = tokenService.issueFor(platformUserId, snapshot.getAccount(), - Collections.singleton(snapshot.getRoleKey())); - String refreshToken = refreshTokenService.issue(platformUserId); - return ApiResponse.success(new SessionResponse(accessToken.accessToken(), refreshToken, "Bearer", accessToken.expiresIn()), - MDC.get(TraceIdFilter.MDC_KEY)); - } - @GetMapping("/me") - public ApiResponse currentUser(@AuthenticationPrincipal Jwt jwt) { - long platformUserId = platformUserId(jwt); - PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(platformUserId); - if (snapshot == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在"); - } - return ApiResponse.success(new CurrentUserResponse(platformUserId, snapshot.getAccount(), snapshot.getDisplayName(), - Collections.singletonList(snapshot.getRoleKey())), MDC.get(TraceIdFilter.MDC_KEY)); + @Operation(description = "获取当前登录用户信息") + public ApiResponse currentUser() { + CurrentUser user = currentUserService.getCurrentUser(); + return ApiResponse.success(new CurrentUserResponse(user.getSchoolId(), user.getSchoolName(), + user.getCollegeId(), user.getCollegeName(), user.getMajorId(), user.getMajorName(), user.getRoleid(), + user.getUserId(), user.getUsername(), user.getName(), user.getClassId(), user.getClassName(), + user.getStudentid()), MDC.get(TraceIdFilter.MDC_KEY)); } @PostMapping("/logout") + @Operation(description = "登出") public ApiResponse logout(@AuthenticationPrincipal Jwt jwt, @Valid @RequestBody LogoutRequest request) { - refreshTokenService.revokeForUser(request.refreshToken(), platformUserId(jwt)); + refreshTokenService.revokeForUser(request.refreshToken(), userId(jwt)); return ApiResponse.success(null, MDC.get(TraceIdFilter.MDC_KEY)); } - private long platformUserId(Jwt jwt) { + private long userId(Jwt jwt) { try { return Long.parseLong(jwt.getSubject()); } catch (NumberFormatException exception) { - throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份无效"); + throw new BusinessException(ErrorCode.UNAUTHORIZED, "User identity is invalid"); } } } diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java b/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java index eb74b4f..24c7e0f 100644 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java @@ -1,4 +1,22 @@ package com.yau.digitalrmb.security.interfaces; -import lombok.AllArgsConstructor; import lombok.Getter; import java.util.List; -@Getter @AllArgsConstructor -public class CurrentUserResponse { private final long platformUserId; private final String account; private final String displayName; private final List roles; } + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class CurrentUserResponse { + private final String schoolId; + private final String schoolName; + private final String collegeId; + private final String collegeName; + private final String majorId; + private final String majorName; + private final Long roleid; + private final long userId; + private final String username; + private final String name; + private final String classId; + private final String className; + private final String studentid; +} diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java b/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java deleted file mode 100644 index 84addff..0000000 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.yau.digitalrmb.security.interfaces; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.validation.constraints.NotBlank; -@Getter @Setter @NoArgsConstructor @AllArgsConstructor -public class ExchangeCodeRequest { @NotBlank private String code; public String code() { return code; } } diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java b/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java deleted file mode 100644 index 9748d9c..0000000 --- a/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.yau.digitalrmb.security.interfaces; -import lombok.AllArgsConstructor; import lombok.Getter; -@Getter @AllArgsConstructor -public class SessionResponse { private final String accessToken; private final String refreshToken; private final String tokenType; private final long expiresIn; } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6b2c878..8a14bb0 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -8,10 +8,8 @@ springdoc: swagger-ui: enabled: true platform-integration: - datasource: - url: ${spring.datasource.url} - username: ${spring.datasource.username} - password: ${spring.datasource.password} + token: + link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY:local-token-sso-test-secret-key-123456} security: jwt: secret: 0123456789012345678901234567890123456789012345678901234567890123 diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 1bbcd72..2316a2e 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -9,18 +9,15 @@ security: secret: 0123456789012345678901234567890123456789012345678901234567890123 access-token-ttl: PT30M platform-integration: +<<<<<<< HEAD enabled: true datasource: url: jdbc:h2:mem:platform;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE username: sa password: test-password +======= +>>>>>>> origin/master token: - max-age: PT2M - teacher-claim-value: teacher - student-claim-value: student - cas: - login-url: https://sso.example.edu/login - validate-url: https://sso.example.edu/p3/serviceValidate - callback-url: https://rmb.example.edu/api/v1/auth/cas/callback + link-secret-key: local-token-sso-test-secret-key-123456 frontend: callback-url: https://rmb.example.edu/sso-callback diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e6342f7..85c016c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,6 +7,8 @@ spring: url: ${DIGITAL_RMB_DB_URL} username: ${DIGITAL_RMB_DB_USERNAME} password: ${DIGITAL_RMB_DB_PASSWORD} + hikari: + minimum-idle: 2 driver-class-name: com.mysql.cj.jdbc.Driver sql: init: @@ -24,17 +26,7 @@ springdoc: swagger-ui: enabled: false platform-integration: - datasource: - url: ${DIGITAL_RMB_PLATFORM_DB_URL} - username: ${DIGITAL_RMB_PLATFORM_DB_USERNAME} - password: ${DIGITAL_RMB_PLATFORM_DB_PASSWORD} token: - max-age: PT2M - teacher-claim-value: teacher - student-claim-value: student - cas: - login-url: ${DIGITAL_RMB_CAS_LOGIN_URL} - validate-url: ${DIGITAL_RMB_CAS_VALIDATE_URL} - callback-url: ${DIGITAL_RMB_CAS_CALLBACK_URL} + link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY} frontend: callback-url: ${DIGITAL_RMB_FRONTEND_CALLBACK_URL} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 4d5165d..3e9453e 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -34,7 +34,17 @@ CREATE TABLE IF NOT EXISTS platform_user_snapshot ( display_name VARCHAR(64) NOT NULL, role_key VARCHAR(16) NOT NULL, source_updated_at TIMESTAMP NOT NULL, - synced_at TIMESTAMP NOT NULL + synced_at TIMESTAMP NOT NULL, + school_id VARCHAR(64) NULL, + school_name VARCHAR(128) NULL, + college_id VARCHAR(64) NULL, + college_name VARCHAR(128) NULL, + major_id VARCHAR(64) NULL, + major_name VARCHAR(128) NULL, + role_id BIGINT NULL, + class_id VARCHAR(64) NULL, + class_name VARCHAR(128) NULL, + student_id VARCHAR(64) NULL ); CREATE TABLE IF NOT EXISTS auth_login_exchange_code ( diff --git a/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java b/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java index fb10e0a..34ec9bc 100644 --- a/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java +++ b/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; import org.springframework.test.context.ActiveProfiles; import static org.assertj.core.api.Assertions.assertThat; @@ -18,13 +19,11 @@ class ApplicationContextTest { private HikariDataSource applicationDataSource; @Autowired - @Qualifier("platformReadOnlyDataSource") - private HikariDataSource platformReadOnlyDataSource; + private ApplicationContext applicationContext; @Test - void applicationDatasourceIsSeparateAndWritable() { - assertThat(applicationDataSource).isNotSameAs(platformReadOnlyDataSource); + void usesOnlyTheWritableLocalDatasource() { assertThat(applicationDataSource.isReadOnly()).isFalse(); - assertThat(platformReadOnlyDataSource.isReadOnly()).isTrue(); + assertThat(applicationContext.getBeansOfType(HikariDataSource.class)).hasSize(1); } } diff --git a/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java b/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java new file mode 100644 index 0000000..cdac355 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java @@ -0,0 +1,68 @@ +package com.yau.digitalrmb.identity; + +import com.yau.digitalrmb.identity.application.LocalSsoAccountService; +import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +import javax.sql.DataSource; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class LocalSsoAccountServiceTest { + @Autowired private LocalSsoAccountService service; + @Autowired private DataSource dataSource; + @Autowired private PasswordEncoder passwordEncoder; + + @Test + void createsLocalUserWithTokenPasswordAndStudentRole() { + service.synchronize(new VerifiedPlatformToken(601L, "sso601", "张三", "first-password", "STUDENT")); + + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + String passwordHash = jdbc.queryForObject("SELECT password_hash FROM sys_user WHERE id = 601", String.class); + Long roleId = jdbc.queryForObject("SELECT role_id FROM sys_user_role WHERE user_id = 601", Long.class); + assertThat(passwordEncoder.matches("first-password", passwordHash)).isTrue(); + assertThat(roleId).isEqualTo(1002L); + } + + @Test + void refreshesLocalPasswordWhenPlatformPasswordChanges() { + service.synchronize(new VerifiedPlatformToken(602L, "sso602", "李四", "old-password", "STUDENT")); + service.synchronize(new VerifiedPlatformToken(602L, "sso602-new", "李四", "new-password", "TEACHER")); + + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + String passwordHash = jdbc.queryForObject("SELECT password_hash FROM sys_user WHERE id = 602", String.class); + Long roleId = jdbc.queryForObject("SELECT role_id FROM sys_user_role WHERE user_id = 602", Long.class); + assertThat(passwordEncoder.matches("new-password", passwordHash)).isTrue(); + assertThat(passwordEncoder.matches("old-password", passwordHash)).isFalse(); + assertThat(roleId).isEqualTo(1001L); + } + + @Test + void synchronizesCompleteProfileIntoLocalSnapshot() { + service.synchronize(completeToken(603L, "sso603", 2L)); + + Map row = new JdbcTemplate(dataSource).queryForMap( + "SELECT school_id, college_name, major_name, role_id, class_name, student_id " + + "FROM platform_user_snapshot WHERE platform_user_id = 603"); + assertThat(row).containsEntry("school_id", "610000") + .containsEntry("college_name", "Computer College") + .containsEntry("major_name", "Software Engineering") + .containsEntry("role_id", 2L) + .containsEntry("class_name", "Class 1") + .containsEntry("student_id", "20240001"); + } + + private VerifiedPlatformToken completeToken(long userId, String username, long roleId) { + return new VerifiedPlatformToken(userId, username, "Test Student", "password", roleId, "STUDENT", + "610000", "Yan'an University", "100", "Computer College", "101", "Software Engineering", + "202401", "Class 1", "20240001"); + } +} diff --git a/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java b/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java deleted file mode 100644 index 9ce2f5a..0000000 --- a/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.yau.digitalrmb.identity; - -import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.platformintegration.domain.PlatformRole; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.security.crypto.password.PasswordEncoder; - -import javax.sql.DataSource; -import java.time.Instant; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest -@ActiveProfiles("test") -class PlatformIdentityProjectionServiceTest { - @Autowired - private PlatformIdentityProjectionService projectionService; - - @Autowired - private DataSource dataSource; - - @Autowired - private PasswordEncoder passwordEncoder; - - @Test - void projectionIsIdempotentAndOwnsExactlyOneRole() { - PlatformActor teacher = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER, - Instant.parse("2026-01-01T00:00:00Z")); - - projectionService.project(teacher); - projectionService.project(teacher); - - JdbcTemplate jdbc = new JdbcTemplate(dataSource); - Integer associationCount = jdbc.queryForObject("SELECT COUNT(*) FROM sys_user_role WHERE user_id = 101", Integer.class); - String role = jdbc.queryForObject("SELECT role_key FROM platform_user_snapshot WHERE platform_user_id = 101", String.class); - String userName = jdbc.queryForObject("SELECT username FROM sys_user WHERE id = 101", String.class); - - assertThat(associationCount).isEqualTo(1); - assertThat(role).isEqualTo("TEACHER"); - assertThat(userName).isEqualTo("t001"); - } - - @Test - void ssoProjectionCreatesUserWithRandomBcryptPassword() { - PlatformActor actor = new PlatformActor(302L, 3L, "sso-user", "教师", PlatformRole.TEACHER, - Instant.parse("2026-01-01T00:00:00Z")); - - projectionService.project(actor); - - String passwordHash = new JdbcTemplate(dataSource) - .queryForObject("SELECT password_hash FROM sys_user WHERE id = 302", String.class); - assertThat(passwordHash).startsWith("$2"); - assertThat(passwordEncoder.matches("EXTERNAL_SSO_ONLY", passwordHash)).isFalse(); - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java deleted file mode 100644 index b5d010b..0000000 --- a/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.yau.digitalrmb.platformintegration.application; - -import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import com.yau.digitalrmb.shared.exception.BusinessException; -import org.junit.jupiter.api.Test; -import org.springframework.util.ReflectionUtils; -import org.springframework.web.client.RestTemplate; -import org.springframework.http.client.SimpleClientHttpRequestFactory; - -import java.lang.reflect.Field; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class CasTicketValidatorTest { - @Test - void configuresFiveSecondTimeoutsForCasRequests() throws Exception { - PlatformIntegrationProperties properties = new PlatformIntegrationProperties(); - CasTicketValidator validator = new CasTicketValidator(properties); - Field restTemplateField = ReflectionUtils.findField(CasTicketValidator.class, "restTemplate"); - ReflectionUtils.makeAccessible(restTemplateField); - RestTemplate restTemplate = (RestTemplate) ReflectionUtils.getField(restTemplateField, validator); - - assertThat(restTemplate.getRequestFactory()).isInstanceOf(SimpleClientHttpRequestFactory.class); - SimpleClientHttpRequestFactory requestFactory = (SimpleClientHttpRequestFactory) restTemplate.getRequestFactory(); - assertThat(readIntField(requestFactory, "connectTimeout")).isEqualTo(5000); - assertThat(readIntField(requestFactory, "readTimeout")).isEqualTo(5000); - } - - @Test - void parsesSuccessfulCasAccountAndRejectsExternalEntityPayloads() { - String success = "" - + "t001" - + ""; - String xxe = "]>" - + "&xxe;"; - - assertThat(CasTicketValidator.parseAccount(success)).isEqualTo("t001"); - assertThatThrownBy(() -> CasTicketValidator.parseAccount(xxe)).isInstanceOf(BusinessException.class); - } - - private int readIntField(Object target, String fieldName) throws Exception { - Field field = ReflectionUtils.findField(target.getClass(), fieldName); - ReflectionUtils.makeAccessible(field); - return (Integer) ReflectionUtils.getField(field, target); - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java index d237664..467d2e3 100644 --- a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java +++ b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java @@ -2,99 +2,101 @@ package com.yau.digitalrmb.platformintegration.application; import com.fasterxml.jackson.databind.ObjectMapper; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.platformintegration.domain.PlatformRole; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; +import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.time.ZoneOffset; import java.util.Base64; import java.util.HashMap; import java.util.Map; -import java.util.Optional; -import java.util.Collections; -import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class PlatformTokenVerifierTest { + private static final String SECRET = "local-token-sso-test-secret-key-123456"; private final Instant now = Instant.parse("2026-08-03T04:00:00Z"); - private final PlatformActor teacher = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER, - Instant.parse("2026-01-01T00:00:00Z")); private PlatformTokenVerifier verifier; @BeforeEach void setUp() { PlatformIntegrationProperties.Token properties = new PlatformIntegrationProperties.Token(); - properties.setMaxAge(Duration.ofMinutes(2)); - properties.setTeacherClaimValue("teacher"); - properties.setStudentClaimValue("student"); - PlatformIdentityRepository repository = new PlatformIdentityRepository() { - @Override - public Optional findByPlatformUserId(long platformUserId) { - return platformUserId == 101L ? Optional.of(teacher) : Optional.empty(); - } - - @Override - public Optional findBySchoolAccount(String schoolAccount) { - return Optional.empty(); - } - - @Override - public List findChangedSince(Instant watermark) { - return Collections.emptyList(); - } - }; - verifier = new PlatformTokenVerifier(repository, properties); + properties.setLinkSecretKey(SECRET); + verifier = new PlatformTokenVerifier(properties, new ObjectMapper(), Clock.fixed(now, ZoneOffset.UTC)); } @Test - void acceptsFreshTeacherTokenSignedWithProfileAddTime() throws Exception { - String token = issueToken(teacher, now); - - VerifiedPlatformToken verified = verifier.verify(token, now); + void acceptsStandardThreeSegmentTokenWithoutPlatformDatabaseIdentity() throws Exception { + VerifiedPlatformToken verified = verifier.verify(token(487L, "tzs001", "new-password", 2L, now.plus(Duration.ofMinutes(5)))); - assertThat(verified.actor()).isEqualTo(teacher); - assertThat(verified.fingerprint()).hasSize(64); + assertThat(verified.getUserId()).isEqualTo(487L); + assertThat(verified.getUsername()).isEqualTo("tzs001"); + assertThat(verified.getRawPassword()).isEqualTo("new-password"); + assertThat(verified.getRoleKey()).isEqualTo("STUDENT"); } @Test - void rejectsTamperedStaleAndRoleMismatchedTokens() throws Exception { - String valid = issueToken(teacher, now); - String stale = issueToken(teacher, now.minus(Duration.ofMinutes(3))); - String wrongRole = issueToken(teacher, now, "student"); + void parsesCompleteUserProfileFromVerifiedToken() throws Exception { + VerifiedPlatformToken verified = verifier.verify(token(487L, "tzs001", "new-password", 2L, + now.plus(Duration.ofMinutes(5)), completeProfile())); - assertThatThrownBy(() -> verifier.verify(valid + "x", now)).isInstanceOf(PlatformTokenException.class); - assertThatThrownBy(() -> verifier.verify(stale, now)).isInstanceOf(PlatformTokenException.class); - assertThatThrownBy(() -> verifier.verify(wrongRole, now)).isInstanceOf(PlatformTokenException.class); + assertThat(verified.getSchoolId()).isEqualTo("610000"); + assertThat(verified.getSchoolName()).isEqualTo("Yan'an University"); + assertThat(verified.getCollegeName()).isEqualTo("Computer College"); + assertThat(verified.getMajorName()).isEqualTo("Software Engineering"); + assertThat(verified.getRoleId()).isEqualTo(2L); + assertThat(verified.getName()).isEqualTo("Test Student"); + assertThat(verified.getClassId()).isEqualTo("202401"); + assertThat(verified.getStudentId()).isEqualTo("20240001"); } - private String issueToken(PlatformActor actor, Instant loginTime) throws Exception { - return issueToken(actor, loginTime, "teacher"); + @Test + void rejectsTamperedExpiredOrIncompleteToken() throws Exception { + String valid = token(487L, "tzs001", "new-password", 3L, now.plus(Duration.ofMinutes(5))); + assertThatThrownBy(() -> verifier.verify(valid + "x")).isInstanceOf(PlatformTokenException.class); + assertThatThrownBy(() -> verifier.verify(token(487L, "tzs001", "new-password", 2L, now.minusSeconds(1)))).isInstanceOf(PlatformTokenException.class); + Map incomplete = new HashMap(); + incomplete.put("userId", 487L); + assertThatThrownBy(() -> verifier.verify(unsignedToken(incomplete, now.plus(Duration.ofMinutes(5))))).isInstanceOf(PlatformTokenException.class); } - private String issueToken(PlatformActor actor, Instant loginTime, String identityClaimValue) throws Exception { - ObjectMapper mapper = new ObjectMapper(); - Map headerClaims = new HashMap(); - headerClaims.put("alg", "HS256"); - headerClaims.put("typ", "JWT"); - Map payloadClaims = new HashMap(); - payloadClaims.put("aud", new String[]{String.valueOf(actor.platformUserId())}); - payloadClaims.put(String.valueOf(actor.profileId()), identityClaimValue); - String header = encode(mapper.writeValueAsBytes(headerClaims)); - String payload = encode(mapper.writeValueAsBytes(payloadClaims)); - String unsigned = header + "." + payload; - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(String.valueOf(actor.tokenSigningTime().toEpochMilli()).getBytes(StandardCharsets.UTF_8), "HmacSHA256")); - return unsigned + "." + encode(mac.doFinal(unsigned.getBytes(StandardCharsets.US_ASCII))) + "." + loginTime.toEpochMilli(); + private String token(long userId, String username, String password, long roleId, Instant expiresAt) throws Exception { + return token(userId, username, password, roleId, expiresAt, new HashMap()); } - - private String encode(byte[] value) { - return Base64.getUrlEncoder().withoutPadding().encodeToString(value); + private String token(long userId, String username, String password, long roleId, Instant expiresAt, + Map profile) throws Exception { + Map claims = new HashMap(); + claims.put("userId", userId); claims.put("username", username); claims.put("password", password); claims.put("roleid", roleId); + claims.putAll(profile); + return unsignedToken(claims, expiresAt); + } + private Map completeProfile() { + Map profile = new HashMap(); + profile.put("name", "Test Student"); + profile.put("schoolId", "610000"); + profile.put("schoolName", "Yan'an University"); + profile.put("collegeId", "100"); + profile.put("collegeName", "Computer College"); + profile.put("majorId", "101"); + profile.put("majorName", "Software Engineering"); + profile.put("classId", "202401"); + profile.put("className", "Class 1"); + profile.put("studentid", "20240001"); + return profile; + } + private String unsignedToken(Map claims, Instant expiresAt) throws Exception { + claims.put("exp", expiresAt.getEpochSecond()); + ObjectMapper mapper = new ObjectMapper(); + Map header = new HashMap(); header.put("alg", "HS256"); + String unsigned = encode(mapper.writeValueAsBytes(header)) + "." + encode(mapper.writeValueAsBytes(claims)); + Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return unsigned + "." + encode(mac.doFinal(unsigned.getBytes(StandardCharsets.US_ASCII))); } + private String encode(byte[] value) { return Base64.getUrlEncoder().withoutPadding().encodeToString(value); } } diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java deleted file mode 100644 index b6ef7c4..0000000 --- a/src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.yau.digitalrmb.platformintegration.config; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.context.annotation.Configuration; - -import java.time.Duration; - -import static org.assertj.core.api.Assertions.assertThat; - -class PlatformIntegrationPropertiesTest { - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(PropertiesConfiguration.class)) - .withPropertyValues( - "platform-integration.datasource.url=jdbc:mysql://localhost:3306/tianze", - "platform-integration.datasource.username=readonly", - "platform-integration.datasource.password=secret", - "platform-integration.token.max-age=PT2M", - "platform-integration.token.teacher-claim-value=teacher", - "platform-integration.token.student-claim-value=student", - "platform-integration.cas.login-url=https://sso.example.edu/login", - "platform-integration.cas.validate-url=https://sso.example.edu/p3/serviceValidate", - "platform-integration.cas.callback-url=https://rmb.example.edu/api/v1/auth/cas/callback", - "platform-integration.frontend.callback-url=https://rmb.example.edu/sso-callback"); - - @Test - void bindsReadOnlyDatasourceAndAuthenticationEndpoints() { - contextRunner.run(context -> { - PlatformIntegrationProperties properties = context.getBean(PlatformIntegrationProperties.class); - - assertThat(properties.getDatasource().getUsername()).isEqualTo("readonly"); - assertThat(properties.getToken().getMaxAge()).isEqualTo(Duration.ofMinutes(2)); - assertThat(properties.getCas().getCallbackUrl()).isEqualTo("https://rmb.example.edu/api/v1/auth/cas/callback"); - }); - } - - @Configuration(proxyBeanMethods = false) - @EnableConfigurationProperties(PlatformIntegrationProperties.class) - static class PropertiesConfiguration { - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java deleted file mode 100644 index 353f32e..0000000 --- a/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.yau.digitalrmb.platformintegration.domain; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class PlatformRoleTest { - - @Test - void mapsOnlyTeacherAndStudentJobTypes() { - assertThat(PlatformRole.fromJobType("JT_S_02")).isEqualTo(PlatformRole.TEACHER); - assertThat(PlatformRole.fromJobType("JT_S_03")).isEqualTo(PlatformRole.STUDENT); - assertThatThrownBy(() -> PlatformRole.fromJobType("JT_S_01")) - .isInstanceOf(IllegalArgumentException.class); - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java deleted file mode 100644 index 4c5b0bc..0000000 --- a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.yau.digitalrmb.platformintegration.infrastructure; - -import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.platformintegration.domain.PlatformRole; -import org.h2.jdbcx.JdbcDataSource; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; - -import javax.sql.DataSource; -import java.sql.Connection; -import java.sql.Statement; -import java.time.Instant; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class JdbcPlatformIdentityRepositoryTest { - private DataSource dataSource; - private JdbcPlatformIdentityRepository repository; - - @BeforeEach - void setUp() throws Exception { - JdbcDataSource source = new JdbcDataSource(); - source.setURL("jdbc:h2:mem:platform_identity;MODE=MySQL;DB_CLOSE_DELAY=-1"); - source.setUser("sa"); - dataSource = source; - execute("DROP ALL OBJECTS"); - createSchema(); - repository = new JdbcPlatformIdentityRepository(new NamedParameterJdbcTemplate(dataSource)); - } - - @Test - void resolvesEnabledTeacherFromCoreUserAndTeacherProfile() throws Exception { - execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (101, 't001', '教师甲', 'S1', 'JT_S_02', 0)"); - execute("INSERT INTO teacher(teacher_id, user_id, teacher_status, add_time) VALUES (1, 101, 1, '2026-01-01 00:00:00')"); - - PlatformActor actor = repository.findByPlatformUserId(101L) - .orElseThrow(() -> new IllegalStateException("teacher not found")); - - assertThat(actor.account()).isEqualTo("t001"); - assertThat(actor.role()).isEqualTo(PlatformRole.TEACHER); - assertThat(actor.profileId()).isEqualTo(1L); - assertThat(actor.tokenSigningTime()).isEqualTo(Instant.parse("2025-12-31T16:00:00Z")); - } - - @Test - void excludesDisabledOrUnsupportedUsers() throws Exception { - execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (201, 's001', '学生甲', 'S1', 'JT_S_03', 0)"); - execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (1, 201, 2, '2026-01-01 00:00:00')"); - execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (202, 'admin', '管理员', 'S1', 'JT_S_01', 0)"); - - assertThat(repository.findByPlatformUserId(201L)).isEmpty(); - assertThat(repository.findByPlatformUserId(202L)).isEmpty(); - } - - @Test - void rejectsDuplicateSchoolAccounts() throws Exception { - execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (301, 'duplicate', 'student one', 'S1', 'JT_S_03', 0)"); - execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (302, 'duplicate', 'student two', 'S1', 'JT_S_03', 0)"); - execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (11, 301, 1, '2026-01-01 00:00:00')"); - execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (12, 302, 1, '2026-01-01 00:00:00')"); - - assertThatThrownBy(() -> repository.findBySchoolAccount("duplicate")) - .isInstanceOf(IncorrectResultSizeDataAccessException.class); - } - - @Test - private void createSchema() throws Exception { - execute("CREATE TABLE core_user(ID BIGINT PRIMARY KEY, CODE VARCHAR(64), NAME VARCHAR(64), STATE VARCHAR(16), JOB_TYPE1 VARCHAR(16), DEL_FLAG INT)"); - execute("CREATE TABLE teacher(teacher_id BIGINT PRIMARY KEY, user_id BIGINT, teacher_status INT, add_time TIMESTAMP)"); - execute("CREATE TABLE student(student_id BIGINT PRIMARY KEY, user_id BIGINT, student_status INT, add_time TIMESTAMP)"); - } - - private void execute(String sql) throws Exception { - try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { - statement.execute(sql); - } - } -} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java index 4d7facd..635c24d 100644 --- a/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java @@ -1,45 +1,44 @@ package com.yau.digitalrmb.platformintegration.interfaces; -import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; +import com.yau.digitalrmb.identity.application.LocalSsoAccountService; import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier; import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.platformintegration.domain.PlatformRole; -import com.yau.digitalrmb.security.application.LoginExchangeCodeService; +import com.yau.digitalrmb.security.application.JwtTokenService; import org.junit.jupiter.api.Test; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import java.time.Instant; +import java.util.Collections; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class PlatformSsoControllerTest { @Test - void ssoRedirectDoesNotLeakIncomingToken() throws Exception { + void ssoRedirectIssuesOnlyLocalToken() throws Exception { PlatformTokenVerifier verifier = mock(PlatformTokenVerifier.class); - PlatformIdentityProjectionService projection = mock(PlatformIdentityProjectionService.class); - LoginExchangeCodeService exchangeCodes = mock(LoginExchangeCodeService.class); + LocalSsoAccountService localAccounts = mock(LocalSsoAccountService.class); + JwtTokenService jwtTokenService = mock(JwtTokenService.class); PlatformIntegrationProperties properties = new PlatformIntegrationProperties(); properties.getFrontend().setCallbackUrl("https://rmb.example.edu/sso-callback"); - PlatformActor actor = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER, - Instant.parse("2026-01-01T00:00:00Z")); - when(verifier.verify(anyString())).thenReturn(new VerifiedPlatformToken(actor, "fingerprint")); - when(exchangeCodes.issue(101L)).thenReturn("one-time-code"); - MockMvc mvc = MockMvcBuilders.standaloneSetup(new PlatformSsoController(verifier, projection, exchangeCodes, properties)).build(); + VerifiedPlatformToken verified = new VerifiedPlatformToken(101L, "t001", "Teacher", "password", "TEACHER"); + when(verifier.verify(anyString())).thenReturn(verified); + when(jwtTokenService.issueFor(101L, "t001", Collections.singleton("TEACHER"))) + .thenReturn(new JwtTokenService.Token("local-system-jwt", 1800L)); + MockMvc mvc = MockMvcBuilders.standaloneSetup( + new PlatformSsoController(verifier, localAccounts, jwtTokenService, properties)).build(); mvc.perform(get("/api/v1/auth/sso").param("token", "incoming-platform-token")) .andExpect(status().isFound()) - .andExpect(header().string("Location", "https://rmb.example.edu/sso-callback?code=one-time-code")) + .andExpect(header().string("Location", "https://rmb.example.edu/sso-callback?token=local-system-jwt")) .andExpect(header().string("Cache-Control", "no-store")) .andExpect(header().string("Referrer-Policy", "no-referrer")); - verify(projection).project(actor); + verify(localAccounts).synchronize(verified); } } diff --git a/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java index c6dcb08..ab26f91 100644 --- a/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java @@ -35,8 +35,8 @@ class AuthControllerTest { mvc.perform(get("/api/v1/auth/me") .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.account").value("tzs001")) - .andExpect(jsonPath("$.data.roles[0]").value("STUDENT")); + .andExpect(jsonPath("$.data.userId").value(487)) + .andExpect(jsonPath("$.data.username").value("tzs001")); } @Test diff --git a/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java index e5f7fd6..52eed24 100644 --- a/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java +++ b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java @@ -1,22 +1,19 @@ package com.yau.digitalrmb.security; -import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; -import com.yau.digitalrmb.platformintegration.domain.PlatformActor; -import com.yau.digitalrmb.platformintegration.domain.PlatformRole; +import com.yau.digitalrmb.identity.application.LocalSsoAccountService; +import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; import com.yau.digitalrmb.security.application.JwtTokenService; import com.yau.digitalrmb.security.application.RefreshTokenService; +import java.util.Collections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import java.time.Instant; -import java.util.Collections; - import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -26,22 +23,19 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @AutoConfigureMockMvc @ActiveProfiles("test") class CurrentUserAndLogoutTest { - @Autowired - private MockMvc mvc; - @Autowired - private PlatformIdentityProjectionService projectionService; - @Autowired - private JwtTokenService jwtTokenService; - @Autowired - private RefreshTokenService refreshTokenService; + @Autowired private MockMvc mvc; + @Autowired private LocalSsoAccountService localSsoAccountService; + @Autowired private JwtTokenService jwtTokenService; + @Autowired private RefreshTokenService refreshTokenService; private String teacherJwt; private String refreshToken; @BeforeEach void setUp() { - projectionService.project(new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER, - Instant.parse("2026-01-01T00:00:00Z"))); + localSsoAccountService.synchronize(new VerifiedPlatformToken(101L, "t001", "Teacher User", "password", 3L, + "TEACHER", "610000", "Yan'an University", "100", "Computer College", "101", + "Software Engineering", "202401", "Class 1", "20240001")); teacherJwt = jwtTokenService.issueFor(101L, "t001", Collections.singleton("TEACHER")).accessToken(); refreshToken = refreshTokenService.issue(101L); } @@ -50,8 +44,19 @@ class CurrentUserAndLogoutTest { void currentUserIsReadonlyTeacherAndLogoutRevokesOwnRefreshToken() throws Exception { mvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + teacherJwt)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.account").value("t001")) - .andExpect(jsonPath("$.data.roles[0]").value("TEACHER")); + .andExpect(jsonPath("$.data.userId").value(101)) + .andExpect(jsonPath("$.data.username").value("t001")) + .andExpect(jsonPath("$.data.name").value("Teacher User")) + .andExpect(jsonPath("$.data.schoolId").value("610000")) + .andExpect(jsonPath("$.data.schoolName").value("Yan'an University")) + .andExpect(jsonPath("$.data.collegeId").value("100")) + .andExpect(jsonPath("$.data.collegeName").value("Computer College")) + .andExpect(jsonPath("$.data.majorId").value("101")) + .andExpect(jsonPath("$.data.majorName").value("Software Engineering")) + .andExpect(jsonPath("$.data.roleid").value(3)) + .andExpect(jsonPath("$.data.classId").value("202401")) + .andExpect(jsonPath("$.data.className").value("Class 1")) + .andExpect(jsonPath("$.data.studentid").value("20240001")); mvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + teacherJwt) .contentType(MediaType.APPLICATION_JSON).content("{\"refreshToken\":\"" + refreshToken + "\"}")) .andExpect(status().isOk()); diff --git a/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java b/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java new file mode 100644 index 0000000..d9acd2a --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java @@ -0,0 +1,77 @@ +package com.yau.digitalrmb.security; + +import com.yau.digitalrmb.identity.application.LocalSsoAccountService; +import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; +import com.yau.digitalrmb.security.application.CurrentUser; +import com.yau.digitalrmb.security.application.CurrentUserService; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import java.time.Instant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@ActiveProfiles("test") +class CurrentUserServiceTest { + @Autowired private CurrentUserService currentUserService; + @Autowired private LocalSsoAccountService localSsoAccountService; + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void readsCompleteProfileForCurrentSecurityContextUser() { + localSsoAccountService.synchronize(completeToken(701L, "sso701", 2L)); + authenticateAs(701L); + + CurrentUser user = currentUserService.getCurrentUser(); + + assertThat(user.getUserId()).isEqualTo(701L); + assertThat(user.getUsername()).isEqualTo("sso701"); + assertThat(user.getName()).isEqualTo("Test Student"); + assertThat(user.getSchoolId()).isEqualTo("610000"); + assertThat(user.getCollegeName()).isEqualTo("Computer College"); + assertThat(user.getMajorName()).isEqualTo("Software Engineering"); + assertThat(user.getRoleid()).isEqualTo(2L); + assertThat(user.getClassId()).isEqualTo("202401"); + assertThat(user.getStudentid()).isEqualTo("20240001"); + } + + @Test + void rejectsCurrentSecurityContextUserWithoutSnapshot() { + authenticateAs(702L); + + assertThatThrownBy(() -> currentUserService.getCurrentUser()) + .isInstanceOfSatisfying(BusinessException.class, + exception -> assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED)); + } + + private void authenticateAs(long userId) { + Jwt jwt = Jwt.withTokenValue("test-token") + .header("alg", "none") + .subject(String.valueOf(userId)) + .issuedAt(Instant.parse("2026-08-04T00:00:00Z")) + .expiresAt(Instant.parse("2026-08-04T01:00:00Z")) + .build(); + AbstractAuthenticationToken authentication = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private VerifiedPlatformToken completeToken(long userId, String username, long roleId) { + return new VerifiedPlatformToken(userId, username, "Test Student", "password", roleId, "STUDENT", + "610000", "Yan'an University", "100", "Computer College", "101", "Software Engineering", + "202401", "Class 1", "20240001"); + } +} diff --git a/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java b/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java deleted file mode 100644 index d3c687b..0000000 --- a/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.yau.digitalrmb.security; - -import com.yau.digitalrmb.security.application.LoginExchangeCodeService; -import com.yau.digitalrmb.shared.exception.BusinessException; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -@SpringBootTest -@ActiveProfiles("test") -class LoginExchangeCodeServiceTest { - @Autowired - private LoginExchangeCodeService service; - - @Test - void exchangeCodeCanOnlyBeUsedOnce() { - String code = service.issue(101L); - - assertThat(service.exchange(code)).isEqualTo(101L); - assertThatThrownBy(() -> service.exchange(code)).isInstanceOf(BusinessException.class); - } -}