Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f6e8187cf | |||
| ab0265c17b | |||
| 8a799719ff | |||
| 8bdaf53222 |
@@ -0,0 +1,31 @@
|
|||||||
|
# Gitea Actions 门禁:与 docs/development/git-workflow.md 的本地门禁完全同一条命令。
|
||||||
|
#
|
||||||
|
# 启用前提(服务器侧,一次性):
|
||||||
|
# 1. Gitea ≥ 1.19 且管理端开启 Actions(app.ini: [actions] ENABLED=true,
|
||||||
|
# 仓库 Settings → Actions 启用)。
|
||||||
|
# 2. 注册一个 act_runner,且 runner 所在主机具备:
|
||||||
|
# - Docker(Testcontainers 需要,跑 postgres:18 一次性容器)
|
||||||
|
# - 标签 ubuntu-latest 映射到含 bash/git 的镜像,或 host 模式执行
|
||||||
|
# 3. 若 runner 无法访问 github.com 拉取 actions/*,可在 runner 配置
|
||||||
|
# [actions].DEFAULT_ACTIONS_URL 指向镜像源,或将下方 setup-java 步骤
|
||||||
|
# 替换为直接使用预装 JDK 17 的 runner 镜像并删除该步骤。
|
||||||
|
#
|
||||||
|
# 未启用 Actions 时本文件无副作用。
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
# 与本地门禁同一命令;BUILD SUCCESS 且 0 失败方可合入(git-workflow.md)
|
||||||
|
- run: ./mvnw -B clean test
|
||||||
@@ -9,3 +9,7 @@ target/
|
|||||||
# Local service configuration (copy the committed .sample to application.yml)
|
# Local service configuration (copy the committed .sample to application.yml)
|
||||||
patbond-*/src/main/resources/application.yml
|
patbond-*/src/main/resources/application.yml
|
||||||
!patbond-*/src/main/resources/application.yml.sample
|
!patbond-*/src/main/resources/application.yml.sample
|
||||||
|
|
||||||
|
# Docker compose 本地机密(deploy/init-secrets.sh 生成,绝不入库)
|
||||||
|
.env
|
||||||
|
deploy/keys/
|
||||||
|
|||||||
@@ -103,6 +103,34 @@ curl -X POST http://127.0.0.1:8081/api/v1/auth/register \
|
|||||||
Machine-specific values live in the git-ignored `application.yml` (copied from the
|
Machine-specific values live in the git-ignored `application.yml` (copied from the
|
||||||
committed `.sample`); never commit secrets to the samples.
|
committed `.sample`); never commit secrets to the samples.
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
MVP 编排(ADR-007):`postgres:18`(数据落 volume)+ 两个无状态应用容器。
|
||||||
|
配置与本机运行同一套约定 —— 容器内挂载 `application.yml.sample` 作为配置,
|
||||||
|
`PATBOND_*` 环境变量注入实际值。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 生成 RS256 密钥对与 .env(DB 口令、内部令牌;产物被 .gitignore 忽略)
|
||||||
|
./deploy/init-secrets.sh
|
||||||
|
|
||||||
|
# 2. 构建可执行 jar
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw -DskipTests package
|
||||||
|
|
||||||
|
# 3. 启动(首次会构建镜像)
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 冒烟
|
||||||
|
curl -s -X POST http://127.0.0.1:8081/api/v1/auth/register \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"demo_user","password":"secret123"}'
|
||||||
|
|
||||||
|
# 停止(-v 同时删除数据库数据)
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:数据库端口不对宿主机发布;`8082`(user)在 MVP 阶段直连暴露以提供
|
||||||
|
`/api/v1/me`,`/internal/**` 由服务间令牌保护,规模化阶段应改由网关统一入口。
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
### Auth Service
|
### Auth Service
|
||||||
|
|||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 生成 docker compose 运行所需的本地机密:RS256 密钥对 + .env(DB 口令、内部令牌)。
|
||||||
|
# 产物全部被 .gitignore 忽略,绝不入库;重复执行是幂等的(已存在则不覆盖)。
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
mkdir -p deploy/keys
|
||||||
|
if [ ! -f deploy/keys/jwt-private.pem ]; then
|
||||||
|
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out deploy/keys/jwt-private.pem
|
||||||
|
echo "已生成 deploy/keys/jwt-private.pem"
|
||||||
|
fi
|
||||||
|
openssl pkey -in deploy/keys/jwt-private.pem -pubout -out deploy/keys/jwt-public.pem
|
||||||
|
echo "已生成 deploy/keys/jwt-public.pem"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
{
|
||||||
|
echo "PATBOND_DB_PASSWORD=$(openssl rand -hex 16)"
|
||||||
|
echo "PATBOND_INTERNAL_TOKEN=$(openssl rand -hex 32)"
|
||||||
|
} > .env
|
||||||
|
echo "已生成 .env(随机 DB 口令与内部令牌)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 容器内以 uid 10001 运行,密钥需可读
|
||||||
|
chmod 644 deploy/keys/jwt-public.pem deploy/keys/jwt-private.pem
|
||||||
|
echo "OK:deploy/keys/ 与 .env 就绪(均已被 .gitignore 忽略)"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Patbond MVP 编排(ADR-007):应用容器无状态,PostgreSQL 数据落 volume。
|
||||||
|
# 使用步骤见 Readme.md「Docker Compose」一节:
|
||||||
|
# 1) ./deploy/init-secrets.sh 生成 RS256 密钥对与 .env(均不入库)
|
||||||
|
# 2) JAVA_HOME=... ./mvnw -DskipTests package
|
||||||
|
# 3) docker compose up -d --build
|
||||||
|
#
|
||||||
|
# 配置来源:容器内挂载各服务的 application.yml.sample 作为配置文件,
|
||||||
|
# 其中的 ${PATBOND_*} 占位由下方 environment 注入 —— 与本机运行同一套约定。
|
||||||
|
name: patbond
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${PATBOND_DB_NAME:-patbond}
|
||||||
|
POSTGRES_USER: ${PATBOND_DB_USER:-patbond}
|
||||||
|
POSTGRES_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
volumes:
|
||||||
|
# postgres:18 官方镜像的挂载点是 /var/lib/postgresql(含版本子目录)
|
||||||
|
- pgdata:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${PATBOND_DB_USER:-patbond} -d ${PATBOND_DB_NAME:-patbond}"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
# 数据库不对宿主机发布端口;调试需要时可临时加 ports: ["15432:5432"]
|
||||||
|
|
||||||
|
user:
|
||||||
|
build: ./patbond-user
|
||||||
|
environment:
|
||||||
|
SPRING_CONFIG_LOCATION: file:/config/application.yml
|
||||||
|
PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond}
|
||||||
|
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
|
||||||
|
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
|
||||||
|
volumes:
|
||||||
|
- ./patbond-user/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||||
|
- ./deploy/keys:/run/patbond/keys:ro
|
||||||
|
# MVP 直连暴露 8082 供客户端访问 /api/v1/me;/internal/** 已有服务间鉴权,
|
||||||
|
# 规模化阶段应由网关统一入口并停止直接暴露本端口。
|
||||||
|
ports:
|
||||||
|
- "${PATBOND_USER_PORT:-8082}:8082"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
auth:
|
||||||
|
build: ./patbond-auth
|
||||||
|
environment:
|
||||||
|
SPRING_CONFIG_LOCATION: file:/config/application.yml
|
||||||
|
PATBOND_USER_SERVICE_URL: http://user:8082
|
||||||
|
PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
PATBOND_JWT_PRIVATE_KEY: /run/patbond/keys/jwt-private.pem
|
||||||
|
volumes:
|
||||||
|
- ./patbond-auth/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||||
|
- ./deploy/keys:/run/patbond/keys:ro
|
||||||
|
ports:
|
||||||
|
- "${PATBOND_AUTH_PORT:-8081}:8081"
|
||||||
|
depends_on:
|
||||||
|
- user
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Runtime image only — build the jar first: ./mvnw -pl patbond-auth -am package
|
||||||
|
# Stateless by design (ADR-007): no local state, config via env / mounted files.
|
||||||
|
FROM eclipse-temurin:17-jre
|
||||||
|
RUN useradd --system --uid 10001 patbond
|
||||||
|
USER patbond
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/patbond-auth-1.0.0-SNAPSHOT-exec.jar app.jar
|
||||||
|
EXPOSE 8081
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
@@ -88,6 +88,22 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<!-- No spring-boot-starter-parent in this build, so the
|
||||||
|
executable-jar repackaging must be bound explicitly. -->
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<!-- Keep the plain jar as the main artifact so other
|
||||||
|
modules can depend on this one (patbond-auth's
|
||||||
|
E2E test does); the runnable fat jar gets the
|
||||||
|
-exec classifier and is what the Dockerfile ships. -->
|
||||||
|
<classifier>exec</classifier>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|||||||
@@ -53,14 +53,19 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private AuthService.ClientInfo clientInfo(HttpServletRequest request) {
|
private AuthService.ClientInfo clientInfo(HttpServletRequest request) {
|
||||||
String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
|
String deviceId = truncate(request.getHeader("X-Device-Id"), 128);
|
||||||
if (userAgent != null && userAgent.length() > 512) {
|
String userAgent = truncate(request.getHeader(HttpHeaders.USER_AGENT), 512);
|
||||||
userAgent = userAgent.substring(0, 512);
|
|
||||||
}
|
|
||||||
String forwarded = request.getHeader("X-Forwarded-For");
|
String forwarded = request.getHeader("X-Forwarded-For");
|
||||||
String ip = forwarded != null && !forwarded.isBlank()
|
String ip = forwarded != null && !forwarded.isBlank()
|
||||||
? forwarded.split(",")[0].trim()
|
? forwarded.split(",")[0].trim()
|
||||||
: request.getRemoteAddr();
|
: request.getRemoteAddr();
|
||||||
return new AuthService.ClientInfo(userAgent, ip);
|
return new AuthService.ClientInfo(deviceId, userAgent, ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String value, int maxLength) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.length() > maxLength ? value.substring(0, maxLength) : value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import java.util.UUID;
|
|||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
/** Device metadata forwarded to the session record (observability only). */
|
/** Device metadata forwarded to the session record (observability only). */
|
||||||
public record ClientInfo(String userAgent, String ipAddress) {
|
public record ClientInfo(String deviceId, String userAgent, String ipAddress) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private final UserClient userClient;
|
private final UserClient userClient;
|
||||||
@@ -88,7 +88,8 @@ public class AuthService {
|
|||||||
|
|
||||||
private AuthTokenResponse openSession(UUID userId, ClientInfo clientInfo) {
|
private AuthTokenResponse openSession(UUID userId, ClientInfo clientInfo) {
|
||||||
SessionTokens tokens = requireData(sessionClient.create(new CreateSessionRequest(
|
SessionTokens tokens = requireData(sessionClient.create(new CreateSessionRequest(
|
||||||
userId, clientInfo.userAgent(), clientInfo.ipAddress())), "创建会话失败");
|
userId, clientInfo.deviceId(), clientInfo.userAgent(), clientInfo.ipAddress())),
|
||||||
|
"创建会话失败");
|
||||||
return assemble(tokens);
|
return assemble(tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -8,6 +8,7 @@ import com.patbond.patbond.auth.support.TestJwtKeys;
|
|||||||
import com.patbond.patbond.common.error.BusinessException;
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
import com.patbond.patbond.common.error.ErrorCode;
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
import com.patbond.patbond.common.response.ApiResponse;
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.common.session.CreateSessionRequest;
|
||||||
import com.patbond.patbond.common.session.RevokeSessionRequest;
|
import com.patbond.patbond.common.session.RevokeSessionRequest;
|
||||||
import com.patbond.patbond.common.session.SessionTokens;
|
import com.patbond.patbond.common.session.SessionTokens;
|
||||||
import com.patbond.patbond.common.user.UserProfile;
|
import com.patbond.patbond.common.user.UserProfile;
|
||||||
@@ -109,7 +110,29 @@ class AuthControllerTest {
|
|||||||
// Frozen contract: exactly these six fields, nothing else.
|
// Frozen contract: exactly these six fields, nothing else.
|
||||||
.andExpect(jsonPath("$.data.username").doesNotExist())
|
.andExpect(jsonPath("$.data.username").doesNotExist())
|
||||||
.andExpect(jsonPath("$.data.nickname").doesNotExist())
|
.andExpect(jsonPath("$.data.nickname").doesNotExist())
|
||||||
.andExpect(jsonPath("$.data.expiresAt").doesNotExist());
|
.andExpect(jsonPath("$.data.expiresAt").doesNotExist())
|
||||||
|
// Envelope is exactly {code, message, data}: the derived
|
||||||
|
// isSuccess() getter must not leak onto the wire.
|
||||||
|
.andExpect(jsonPath("$.success").doesNotExist());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerForwardsDeviceIdHeaderToTheSessionRecord() throws Exception {
|
||||||
|
when(userClient.createUser(any())).thenReturn(ApiResponse.success(
|
||||||
|
new UserProfile(USER_ID, "alice", null, "+8613800138000", OffsetDateTime.now())));
|
||||||
|
when(sessionClient.create(any())).thenReturn(ApiResponse.success(sessionTokens()));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/register")
|
||||||
|
.header("X-Device-Id", "pixel-8-of-alice")
|
||||||
|
.contentType(APPLICATION_JSON)
|
||||||
|
.content(REGISTER_BODY))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
ArgumentCaptor<CreateSessionRequest> captor =
|
||||||
|
ArgumentCaptor.forClass(CreateSessionRequest.class);
|
||||||
|
verify(sessionClient).create(captor.capture());
|
||||||
|
assertThat(captor.getValue().getDeviceId()).isEqualTo("pixel-8-of-alice");
|
||||||
|
assertThat(captor.getValue().getUserId()).isEqualTo(USER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -25,6 +25,12 @@
|
|||||||
<groupId>jakarta.validation</groupId>
|
<groupId>jakarta.validation</groupId>
|
||||||
<artifactId>jakarta.validation-api</artifactId>
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Annotations only (no databind): keeps the wire shape of the shared
|
||||||
|
envelope under the contract module's control. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-annotations</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.patbond.patbond.common.response;
|
package com.patbond.patbond.common.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
|
||||||
public class ApiResponse<T> {
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
private Integer code;
|
private Integer code;
|
||||||
@@ -23,6 +25,11 @@ public class ApiResponse<T> {
|
|||||||
return new ApiResponse<>(code, message, null);
|
return new ApiResponse<>(code, message, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived convenience only — not part of the frozen wire contract
|
||||||
|
* {@code {code, message, data}}, hence never serialized.
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
public boolean isSuccess() {
|
public boolean isSuccess() {
|
||||||
return Integer.valueOf(0).equals(code);
|
return Integer.valueOf(0).equals(code);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -15,6 +15,9 @@ public class CreateSessionRequest {
|
|||||||
@NotNull(message = "userId 不能为空")
|
@NotNull(message = "userId 不能为空")
|
||||||
private UUID userId;
|
private UUID userId;
|
||||||
|
|
||||||
|
@Size(max = 128, message = "deviceId 长度不能超过128位")
|
||||||
|
private String deviceId;
|
||||||
|
|
||||||
@Size(max = 512, message = "userAgent 长度不能超过512位")
|
@Size(max = 512, message = "userAgent 长度不能超过512位")
|
||||||
private String userAgent;
|
private String userAgent;
|
||||||
|
|
||||||
@@ -24,8 +27,9 @@ public class CreateSessionRequest {
|
|||||||
public CreateSessionRequest() {
|
public CreateSessionRequest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateSessionRequest(UUID userId, String userAgent, String ipAddress) {
|
public CreateSessionRequest(UUID userId, String deviceId, String userAgent, String ipAddress) {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
|
this.deviceId = deviceId;
|
||||||
this.userAgent = userAgent;
|
this.userAgent = userAgent;
|
||||||
this.ipAddress = ipAddress;
|
this.ipAddress = ipAddress;
|
||||||
}
|
}
|
||||||
@@ -38,6 +42,14 @@ public class CreateSessionRequest {
|
|||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDeviceId() {
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceId(String deviceId) {
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getUserAgent() {
|
public String getUserAgent() {
|
||||||
return userAgent;
|
return userAgent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Runtime image only — build the jar first: ./mvnw -pl patbond-user -am package
|
||||||
|
# Stateless by design (ADR-007): no local state, config via env / mounted files.
|
||||||
|
FROM eclipse-temurin:17-jre
|
||||||
|
RUN useradd --system --uid 10001 patbond
|
||||||
|
USER patbond
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/patbond-user-1.0.0-SNAPSHOT-exec.jar app.jar
|
||||||
|
EXPOSE 8082
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
@@ -98,6 +98,22 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<!-- No spring-boot-starter-parent in this build, so the
|
||||||
|
executable-jar repackaging must be bound explicitly. -->
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<!-- Keep the plain jar as the main artifact so other
|
||||||
|
modules can depend on this one (patbond-auth's
|
||||||
|
E2E test does); the runnable fat jar gets the
|
||||||
|
-exec classifier and is what the Dockerfile ships. -->
|
||||||
|
<classifier>exec</classifier>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ public class SessionRepository {
|
|||||||
|
|
||||||
public void insert(UUID id, UUID userId, UUID tokenFamilyId, byte[] refreshTokenHash,
|
public void insert(UUID id, UUID userId, UUID tokenFamilyId, byte[] refreshTokenHash,
|
||||||
String accessTokenJti, OffsetDateTime expiresAt,
|
String accessTokenJti, OffsetDateTime expiresAt,
|
||||||
String userAgent, String ipAddress) {
|
String deviceId, String userAgent, String ipAddress) {
|
||||||
jdbcClient.sql("""
|
jdbcClient.sql("""
|
||||||
INSERT INTO identity.auth_sessions
|
INSERT INTO identity.auth_sessions
|
||||||
(id, user_id, token_family_id, refresh_token_hash, access_token_jti,
|
(id, user_id, token_family_id, refresh_token_hash, access_token_jti,
|
||||||
expires_at, user_agent, ip_address)
|
expires_at, device_id, user_agent, ip_address)
|
||||||
VALUES (:id, :userId, :familyId, :hash, :jti, :expiresAt,
|
VALUES (:id, :userId, :familyId, :hash, :jti, :expiresAt,
|
||||||
:userAgent, CAST(:ipAddress AS inet))
|
:deviceId, :userAgent, CAST(:ipAddress AS inet))
|
||||||
""")
|
""")
|
||||||
.param("id", id)
|
.param("id", id)
|
||||||
.param("userId", userId)
|
.param("userId", userId)
|
||||||
@@ -41,6 +41,7 @@ public class SessionRepository {
|
|||||||
.param("hash", refreshTokenHash)
|
.param("hash", refreshTokenHash)
|
||||||
.param("jti", accessTokenJti)
|
.param("jti", accessTokenJti)
|
||||||
.param("expiresAt", expiresAt)
|
.param("expiresAt", expiresAt)
|
||||||
|
.param("deviceId", deviceId)
|
||||||
.param("userAgent", userAgent)
|
.param("userAgent", userAgent)
|
||||||
.param("ipAddress", ipAddress)
|
.param("ipAddress", ipAddress)
|
||||||
.update();
|
.update();
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public class SessionService {
|
|||||||
/** Opens a new session (= new token family) for a freshly authenticated user. */
|
/** Opens a new session (= new token family) for a freshly authenticated user. */
|
||||||
public SessionTokens create(CreateSessionRequest request) {
|
public SessionTokens create(CreateSessionRequest request) {
|
||||||
return insertSession(request.getUserId(), UuidV7.generate(),
|
return insertSession(request.getUserId(), UuidV7.generate(),
|
||||||
request.getUserAgent(), request.getIpAddress());
|
request.getDeviceId(), request.getUserAgent(), request.getIpAddress());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,7 +75,8 @@ public class SessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SessionTokens rotated = transactionTemplate.execute(status -> {
|
SessionTokens rotated = transactionTemplate.execute(status -> {
|
||||||
SessionTokens tokens = insertSession(session.userId(), session.tokenFamilyId(), null, null);
|
SessionTokens tokens = insertSession(session.userId(), session.tokenFamilyId(),
|
||||||
|
null, null, null);
|
||||||
if (sessionRepository.markRotated(session.id(), tokens.getSessionId()) != 1) {
|
if (sessionRepository.markRotated(session.id(), tokens.getSessionId()) != 1) {
|
||||||
status.setRollbackOnly();
|
status.setRollbackOnly();
|
||||||
return null;
|
return null;
|
||||||
@@ -98,7 +99,8 @@ public class SessionService {
|
|||||||
sessionRepository.revokeByTokenHashAndUser(sha256(refreshToken), userId, "logout");
|
sessionRepository.revokeByTokenHashAndUser(sha256(refreshToken), userId, "logout");
|
||||||
}
|
}
|
||||||
|
|
||||||
private SessionTokens insertSession(UUID userId, UUID familyId, String userAgent, String ipAddress) {
|
private SessionTokens insertSession(UUID userId, UUID familyId,
|
||||||
|
String deviceId, String userAgent, String ipAddress) {
|
||||||
UUID sessionId = UuidV7.generate();
|
UUID sessionId = UuidV7.generate();
|
||||||
String jti = UuidV7.generate().toString();
|
String jti = UuidV7.generate().toString();
|
||||||
byte[] tokenBytes = new byte[32];
|
byte[] tokenBytes = new byte[32];
|
||||||
@@ -108,7 +110,7 @@ public class SessionService {
|
|||||||
.plus(properties.getSession().getRefreshTtl());
|
.plus(properties.getSession().getRefreshTtl());
|
||||||
|
|
||||||
sessionRepository.insert(sessionId, userId, familyId, sha256(refreshToken), jti,
|
sessionRepository.insert(sessionId, userId, familyId, sha256(refreshToken), jti,
|
||||||
expiresAt, userAgent, ipAddress);
|
expiresAt, deviceId, userAgent, ipAddress);
|
||||||
return new SessionTokens(sessionId, userId, jti, refreshToken, expiresAt);
|
return new SessionTokens(sessionId, userId, jti, refreshToken, expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -57,7 +57,8 @@ class SessionLifecycleIntegrationTest {
|
|||||||
|
|
||||||
private Map<String, Object> createSession(String userId) throws Exception {
|
private Map<String, Object> createSession(String userId) throws Exception {
|
||||||
String body = mockMvc.perform(internalPost("/internal/sessions")
|
String body = mockMvc.perform(internalPost("/internal/sessions")
|
||||||
.content("{\"userId\":\"%s\",\"userAgent\":\"junit\",\"ipAddress\":\"127.0.0.1\"}"
|
.content(("{\"userId\":\"%s\",\"deviceId\":\"junit-device\","
|
||||||
|
+ "\"userAgent\":\"junit\",\"ipAddress\":\"127.0.0.1\"}")
|
||||||
.formatted(userId)))
|
.formatted(userId)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(0))
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
@@ -91,6 +92,13 @@ class SessionLifecycleIntegrationTest {
|
|||||||
assertThat(storedHash).isEqualTo(expected).hasSize(32);
|
assertThat(storedHash).isEqualTo(expected).hasSize(32);
|
||||||
// The plaintext token appears nowhere in the row.
|
// The plaintext token appears nowhere in the row.
|
||||||
assertThat(new String(storedHash, StandardCharsets.ISO_8859_1)).isNotEqualTo(refreshToken);
|
assertThat(new String(storedHash, StandardCharsets.ISO_8859_1)).isNotEqualTo(refreshToken);
|
||||||
|
|
||||||
|
String deviceId = jdbcClient.sql(
|
||||||
|
"SELECT device_id FROM identity.auth_sessions WHERE id = :id")
|
||||||
|
.param("id", UUID.fromString((String) session.get("sessionId")))
|
||||||
|
.query(String.class)
|
||||||
|
.single();
|
||||||
|
assertThat(deviceId).isEqualTo("junit-device");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user