Skip to content

Commit cbb9782

Browse files
committed
feat(p0-p1): harden auth rotation and add teacher effective-time analytics
1 parent 3e9ad20 commit cbb9782

24 files changed

+419
-30
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,11 @@ Abrir: `http://localhost:5173`
5050
4. Para continuar luego, usa `Cargar sesion` con el `sessionId` (tambien se recuerda automaticamente en el navegador).
5151

5252
## Release desktop
53-
- Release publico actual: `v3.4.0`
53+
- Release publico actual: `v3.5.0`
5454
- Incluye `AutoBookQuest-win64.zip` (portable con `AutoBookQuest.exe`).
5555
- Para instalador `.exe` tipo setup con `jpackage`, se requiere WiX v3 instalado.
5656
- El workflow `release` publica tambien `latest.json` para auto-update.
57+
- Notas del release: `docs/RELEASE_NOTES_v3.5.0.md`.
5758

5859
## Calidad
5960
- Backend: `mvn test`

apps/backend/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ mvn spring-boot:run
3333
- `GET /api/teacher/classrooms/{classroomId}/report.csv?from=YYYY-MM-DD&to=YYYY-MM-DD`
3434

3535
### Seguridad API (P0)
36-
- Header requerido para endpoints `/api/**` (excepto `/api/health`): `X-Api-Token`.
37-
- Alternativa recomendada: `Authorization: Bearer <accessToken>` emitido por `/api/auth/login`.
36+
- Recomendado: `Authorization: Bearer <accessToken>` emitido por `/api/auth/login`.
37+
- Compatibilidad legacy opcional: `X-Api-Token` (controlado por `app.security.allow-legacy-token`).
3838
- Tokens por rol configurables en `application.properties`:
3939
- `app.security.student-token`
4040
- `app.security.teacher-token`
@@ -45,7 +45,9 @@ mvn spring-boot:run
4545
- `app.security.admin-username` / `app.security.admin-password`
4646
- Firma y expiracion del token bearer:
4747
- `app.security.jwt-secret`
48+
- `app.security.jwt-previous-secret` (ventana de rotacion)
4849
- `app.security.jwt-ttl-seconds`
50+
- `app.security.allow-legacy-token`
4951
- Rate limit basico configurable:
5052
- `app.rate-limit.window-seconds`
5153
- `app.rate-limit.max-requests`
@@ -67,5 +69,6 @@ mvn spring-boot:run
6769
- Persistencia docente sobre JDBC + Flyway (`classrooms`, `students`, `assignments`, `attempts`).
6870
- Persistencia runtime de sesiones de juego sobre JDBC + Flyway (`game_sessions`).
6971
- Vinculacion de intentos docente protegida contra duplicados (`student_id + assignment_id + session_id`).
72+
- Dashboard docente incluye tiempo efectivo y abandono por actividad real.
7073
- Default local con H2 file DB; PostgreSQL habilitado por variables de entorno Spring datasource.
7174
- Importacion de libros restringida a `.txt` y `.pdf` con limite configurable (`app.import.max-bytes`, default 25MB).
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.juegodefinitivo.autobook.api.dto;
2+
3+
public record ActivityAbandonmentView(
4+
String eventType,
5+
int activeAttempts,
6+
int activeRatePercent
7+
) {
8+
}

apps/backend/src/main/java/com/juegodefinitivo/autobook/api/dto/ClassroomDashboardResponse.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ public record ClassroomDashboardResponse(
1111
int activeAttempts,
1212
int completedAttempts,
1313
int abandonmentRatePercent,
14+
int totalEffectiveReadingMinutes,
15+
int averageEffectiveMinutesPerAttempt,
16+
List<ActivityAbandonmentView> abandonmentByActivity,
1417
List<StudentProgressView> studentProgress
1518
) {
1619
}

apps/backend/src/main/java/com/juegodefinitivo/autobook/api/dto/StudentProgressView.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public record StudentProgressView(
88
int averageScore,
99
int averageCorrectAnswers,
1010
int averageProgressPercent,
11+
int averageEffectiveMinutes,
1112
String dominantDifficulty
1213
) {
1314
}

apps/backend/src/main/java/com/juegodefinitivo/autobook/persistence/game/GameSessionRuntimeRepository.java

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import java.util.List;
1414
import java.util.Map;
1515
import java.util.Optional;
16+
import java.util.stream.Collectors;
1617

1718
@Repository
1819
public class GameSessionRuntimeRepository {
@@ -132,7 +133,7 @@ public Optional<StoredSession> load(String sessionId) {
132133
SELECT session_id, player_name, book_path, book_title, current_scene,
133134
life, knowledge, courage, focus, score, correct_answers,
134135
discoveries, completed, inventory_json, narrative_memory_json,
135-
challenge_attempts, challenge_correct, last_message
136+
challenge_attempts, challenge_correct, last_message, updated_at
136137
FROM game_sessions
137138
WHERE session_id = ?
138139
""",
@@ -157,14 +158,34 @@ public Optional<StoredSession> load(String sessionId) {
157158
fromJson(rs.getString("narrative_memory_json")),
158159
rs.getInt("challenge_attempts"),
159160
rs.getInt("challenge_correct"),
160-
rs.getString("last_message")
161+
rs.getString("last_message"),
162+
rs.getTimestamp("updated_at").toInstant()
161163
);
162164
},
163165
sessionId
164166
);
165167
return rows.stream().findFirst();
166168
}
167169

170+
public Map<String, Instant> findLastUpdatedAt(List<String> sessionIds) {
171+
if (sessionIds == null || sessionIds.isEmpty()) {
172+
return Map.of();
173+
}
174+
String placeholders = sessionIds.stream().map(id -> "?").collect(Collectors.joining(", "));
175+
String sql = "SELECT session_id, updated_at FROM game_sessions WHERE session_id IN (" + placeholders + ")";
176+
return jdbcTemplate.query(
177+
sql,
178+
(rs) -> {
179+
Map<String, Instant> bySession = new LinkedHashMap<>();
180+
while (rs.next()) {
181+
bySession.put(rs.getString("session_id"), rs.getTimestamp("updated_at").toInstant());
182+
}
183+
return bySession;
184+
},
185+
sessionIds.toArray()
186+
);
187+
}
188+
168189
private String toJson(Map<String, Integer> value) {
169190
try {
170191
return objectMapper.writeValueAsString(value == null ? Map.of() : value);
@@ -197,7 +218,8 @@ public record StoredSession(
197218
Map<String, Integer> narrativeMemory,
198219
int challengeAttempts,
199220
int challengeCorrect,
200-
String lastMessage
221+
String lastMessage,
222+
Instant updatedAt
201223
) {
202224
}
203225
}

apps/backend/src/main/java/com/juegodefinitivo/autobook/security/ApiAuthInterceptor.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ private ApiRole resolveRole(HttpServletRequest request) {
6262
return verified.get().role();
6363
}
6464
}
65+
if (!properties.allowLegacyToken()) {
66+
return null;
67+
}
6568
String token = request.getHeader(TOKEN_HEADER);
6669
if (token == null || token.isBlank()) {
6770
return null;

apps/backend/src/main/java/com/juegodefinitivo/autobook/security/ApiSecurityProperties.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ public class ApiSecurityProperties {
1717
private final String adminUsername;
1818
private final String adminPassword;
1919
private final String jwtSecret;
20+
private final String jwtPreviousSecret;
2021
private final long jwtTtlSeconds;
22+
private final boolean allowLegacyToken;
2123

2224
public ApiSecurityProperties(
2325
@Value("${app.security.enabled:true}") boolean enabled,
@@ -31,7 +33,9 @@ public ApiSecurityProperties(
3133
@Value("${app.security.admin-username:admin}") String adminUsername,
3234
@Value("${app.security.admin-password:admin-pass}") String adminPassword,
3335
@Value("${app.security.jwt-secret:change-this-secret-in-production}") String jwtSecret,
34-
@Value("${app.security.jwt-ttl-seconds:28800}") long jwtTtlSeconds
36+
@Value("${app.security.jwt-previous-secret:}") String jwtPreviousSecret,
37+
@Value("${app.security.jwt-ttl-seconds:28800}") long jwtTtlSeconds,
38+
@Value("${app.security.allow-legacy-token:true}") boolean allowLegacyToken
3539
) {
3640
this.enabled = enabled;
3741
this.studentToken = studentToken == null ? "" : studentToken.trim();
@@ -44,7 +48,9 @@ public ApiSecurityProperties(
4448
this.adminUsername = adminUsername == null ? "" : adminUsername.trim();
4549
this.adminPassword = adminPassword == null ? "" : adminPassword.trim();
4650
this.jwtSecret = jwtSecret == null ? "" : jwtSecret.trim();
51+
this.jwtPreviousSecret = jwtPreviousSecret == null ? "" : jwtPreviousSecret.trim();
4752
this.jwtTtlSeconds = Math.max(300, jwtTtlSeconds);
53+
this.allowLegacyToken = allowLegacyToken;
4854
}
4955

5056
public boolean enabled() {
@@ -94,4 +100,12 @@ public String jwtSecret() {
94100
public long jwtTtlSeconds() {
95101
return jwtTtlSeconds;
96102
}
103+
104+
public String jwtPreviousSecret() {
105+
return jwtPreviousSecret;
106+
}
107+
108+
public boolean allowLegacyToken() {
109+
return allowLegacyToken;
110+
}
97111
}

apps/backend/src/main/java/com/juegodefinitivo/autobook/security/AuthTokenService.java

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.security.MessageDigest;
1111
import java.time.Instant;
1212
import java.util.Base64;
13+
import java.util.List;
1314
import java.util.Map;
1415
import java.util.Optional;
1516

@@ -28,6 +29,9 @@ public AuthTokenService(ApiSecurityProperties properties, ObjectMapper objectMap
2829

2930
public IssuedToken issue(String username, ApiRole role) {
3031
try {
32+
if (properties.jwtSecret().isBlank()) {
33+
throw new IllegalStateException("JWT secret vacio. Configura app.security.jwt-secret.");
34+
}
3135
long issuedAt = Instant.now().getEpochSecond();
3236
long expiresAt = issuedAt + properties.jwtTtlSeconds();
3337
Map<String, Object> payload = Map.of(
@@ -57,8 +61,7 @@ public Optional<VerifiedToken> verify(String token) {
5761
}
5862
String payloadEncoded = parts[0];
5963
String signature = parts[1];
60-
String expected = sign(payloadEncoded);
61-
if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8))) {
64+
if (!verifySignature(payloadEncoded, signature)) {
6265
return Optional.empty();
6366
}
6467
byte[] payloadBytes = Base64.getUrlDecoder().decode(payloadEncoded);
@@ -76,9 +79,30 @@ public Optional<VerifiedToken> verify(String token) {
7679
}
7780
}
7881

82+
private boolean verifySignature(String payloadEncoded, String signature) throws Exception {
83+
for (String secret : candidateSecrets()) {
84+
if (secret.isBlank()) {
85+
continue;
86+
}
87+
String expected = sign(payloadEncoded, secret);
88+
if (MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8))) {
89+
return true;
90+
}
91+
}
92+
return false;
93+
}
94+
95+
private List<String> candidateSecrets() {
96+
return List.of(properties.jwtSecret(), properties.jwtPreviousSecret());
97+
}
98+
7999
private String sign(String payloadEncoded) throws Exception {
100+
return sign(payloadEncoded, properties.jwtSecret());
101+
}
102+
103+
private String sign(String payloadEncoded, String secret) throws Exception {
80104
Mac mac = Mac.getInstance("HmacSHA256");
81-
SecretKeySpec key = new SecretKeySpec(properties.jwtSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256");
105+
SecretKeySpec key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
82106
mac.init(key);
83107
byte[] digest = mac.doFinal(payloadEncoded.getBytes(StandardCharsets.UTF_8));
84108
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);

apps/backend/src/main/java/com/juegodefinitivo/autobook/service/TeacherWorkspaceService.java

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.juegodefinitivo.autobook.service;
22

33
import com.juegodefinitivo.autobook.api.dto.AssignmentView;
4+
import com.juegodefinitivo.autobook.api.dto.ActivityAbandonmentView;
45
import com.juegodefinitivo.autobook.api.dto.ClassroomDashboardResponse;
56
import com.juegodefinitivo.autobook.api.dto.ClassroomView;
67
import com.juegodefinitivo.autobook.api.dto.GameStateResponse;
78
import com.juegodefinitivo.autobook.api.dto.StudentProgressView;
89
import com.juegodefinitivo.autobook.api.dto.StudentView;
10+
import com.juegodefinitivo.autobook.persistence.game.GameSessionRuntimeRepository;
911
import com.juegodefinitivo.autobook.persistence.teacher.TeacherWorkspaceRepository;
1012
import org.springframework.stereotype.Service;
1113

@@ -24,10 +26,16 @@ public class TeacherWorkspaceService {
2426

2527
private final TeacherWorkspaceRepository repository;
2628
private final GameFacadeService gameFacadeService;
29+
private final GameSessionRuntimeRepository runtimeRepository;
2730

28-
public TeacherWorkspaceService(TeacherWorkspaceRepository repository, GameFacadeService gameFacadeService) {
31+
public TeacherWorkspaceService(
32+
TeacherWorkspaceRepository repository,
33+
GameFacadeService gameFacadeService,
34+
GameSessionRuntimeRepository runtimeRepository
35+
) {
2936
this.repository = repository;
3037
this.gameFacadeService = gameFacadeService;
38+
this.runtimeRepository = runtimeRepository;
3139
}
3240

3341
public List<ClassroomView> listClassrooms() {
@@ -122,10 +130,15 @@ public ClassroomDashboardResponse getDashboard(String classroomId, LocalDate fro
122130
List<TeacherWorkspaceRepository.StudentRow> students = repository.listStudents(classroomId);
123131
List<TeacherWorkspaceRepository.AssignmentRow> assignments = repository.listAssignments(classroomId);
124132
List<TeacherWorkspaceRepository.AttemptRow> attempts = loadAttempts(classroomId, from, to);
133+
Map<String, Instant> runtimeUpdatesBySession = runtimeRepository.findLastUpdatedAt(
134+
attempts.stream().map(TeacherWorkspaceRepository.AttemptRow::sessionId).toList()
135+
);
125136

126137
List<StudentProgressView> progressViews = new ArrayList<>();
127138
int totalAttempts = 0;
128139
int totalCompletedAttempts = 0;
140+
int totalEffectiveMinutes = 0;
141+
Map<String, Integer> activeByActivity = new LinkedHashMap<>();
129142
for (TeacherWorkspaceRepository.StudentRow student : students) {
130143
List<TeacherWorkspaceRepository.AttemptRow> studentAttempts = attempts.stream()
131144
.filter(attempt -> attempt.studentId().equals(student.id()))
@@ -141,8 +154,20 @@ public ClassroomDashboardResponse getDashboard(String classroomId, LocalDate fro
141154
int avgProgress = (int) states.stream().mapToInt(this::progressPercent).average().orElse(0);
142155
int completed = (int) states.stream().filter(GameStateResponse::completed).count();
143156
String difficulty = dominantDifficulty(states);
157+
int totalMinutesByStudent = studentAttempts.stream()
158+
.mapToInt(attempt -> effectiveMinutes(attempt, runtimeUpdatesBySession))
159+
.sum();
160+
int avgEffectiveMinutes = studentAttempts.isEmpty() ? 0 : totalMinutesByStudent / studentAttempts.size();
144161
totalAttempts += studentAttempts.size();
145162
totalCompletedAttempts += completed;
163+
totalEffectiveMinutes += totalMinutesByStudent;
164+
165+
for (GameStateResponse state : states) {
166+
if (!state.completed()) {
167+
String eventType = state.currentScene() == null ? "UNKNOWN" : state.currentScene().eventType();
168+
activeByActivity.merge(eventType, 1, Integer::sum);
169+
}
170+
}
146171

147172
progressViews.add(new StudentProgressView(
148173
student.id(),
@@ -152,6 +177,7 @@ public ClassroomDashboardResponse getDashboard(String classroomId, LocalDate fro
152177
avgScore,
153178
avgCorrect,
154179
avgProgress,
180+
avgEffectiveMinutes,
155181
difficulty
156182
));
157183
}
@@ -160,6 +186,15 @@ public ClassroomDashboardResponse getDashboard(String classroomId, LocalDate fro
160186
int abandonmentRatePercent = totalAttempts == 0
161187
? 0
162188
: (int) ((activeAttempts / (double) totalAttempts) * 100);
189+
int averageEffectiveMinutesPerAttempt = totalAttempts == 0 ? 0 : totalEffectiveMinutes / totalAttempts;
190+
List<ActivityAbandonmentView> abandonmentByActivity = activeByActivity.entrySet().stream()
191+
.map(entry -> new ActivityAbandonmentView(
192+
entry.getKey(),
193+
entry.getValue(),
194+
activeAttempts == 0 ? 0 : (int) ((entry.getValue() / (double) activeAttempts) * 100)
195+
))
196+
.sorted((left, right) -> Integer.compare(right.activeAttempts(), left.activeAttempts()))
197+
.toList();
163198

164199
return new ClassroomDashboardResponse(
165200
classroom.id(),
@@ -170,6 +205,9 @@ public ClassroomDashboardResponse getDashboard(String classroomId, LocalDate fro
170205
activeAttempts,
171206
totalCompletedAttempts,
172207
abandonmentRatePercent,
208+
totalEffectiveMinutes,
209+
averageEffectiveMinutesPerAttempt,
210+
abandonmentByActivity,
173211
progressViews
174212
);
175213
}
@@ -181,7 +219,7 @@ public String exportClassroomCsv(String classroomId) {
181219
public String exportClassroomCsv(String classroomId, LocalDate from, LocalDate to) {
182220
ClassroomDashboardResponse dashboard = getDashboard(classroomId, from, to);
183221
StringBuilder csv = new StringBuilder();
184-
csv.append("classroom_id,classroom_name,teacher,student_id,student_name,attempts,completed_attempts,avg_score,avg_correct_answers,avg_progress_percent,dominant_difficulty")
222+
csv.append("classroom_id,classroom_name,teacher,student_id,student_name,attempts,completed_attempts,avg_score,avg_correct_answers,avg_progress_percent,avg_effective_minutes,dominant_difficulty")
185223
.append('\n');
186224
for (StudentProgressView view : dashboard.studentProgress()) {
187225
csv.append(csv(dashboard.classroomId())).append(',')
@@ -194,9 +232,25 @@ public String exportClassroomCsv(String classroomId, LocalDate from, LocalDate t
194232
.append(view.averageScore()).append(',')
195233
.append(view.averageCorrectAnswers()).append(',')
196234
.append(view.averageProgressPercent()).append(',')
235+
.append(view.averageEffectiveMinutes()).append(',')
197236
.append(csv(view.dominantDifficulty()))
198237
.append('\n');
199238
}
239+
csv.append('\n');
240+
csv.append("summary_metric,summary_value").append('\n');
241+
csv.append("active_attempts,").append(dashboard.activeAttempts()).append('\n');
242+
csv.append("completed_attempts,").append(dashboard.completedAttempts()).append('\n');
243+
csv.append("abandonment_rate_percent,").append(dashboard.abandonmentRatePercent()).append('\n');
244+
csv.append("total_effective_reading_minutes,").append(dashboard.totalEffectiveReadingMinutes()).append('\n');
245+
csv.append("average_effective_minutes_per_attempt,").append(dashboard.averageEffectiveMinutesPerAttempt()).append('\n');
246+
csv.append('\n');
247+
csv.append("abandonment_activity,active_attempts,active_rate_percent").append('\n');
248+
for (ActivityAbandonmentView activity : dashboard.abandonmentByActivity()) {
249+
csv.append(csv(activity.eventType())).append(',')
250+
.append(activity.activeAttempts()).append(',')
251+
.append(activity.activeRatePercent())
252+
.append('\n');
253+
}
200254
return csv.toString();
201255
}
202256

@@ -298,6 +352,13 @@ private String dominantDifficulty(List<GameStateResponse> states) {
298352
.orElse("UNKNOWN");
299353
}
300354

355+
private int effectiveMinutes(TeacherWorkspaceRepository.AttemptRow attempt, Map<String, Instant> runtimeUpdatesBySession) {
356+
Instant updated = runtimeUpdatesBySession.getOrDefault(attempt.sessionId(), attempt.createdAt());
357+
long seconds = Math.max(0L, updated.getEpochSecond() - attempt.createdAt().getEpochSecond());
358+
int minutes = (int) Math.max(1, seconds / 60);
359+
return Math.min(minutes, 240);
360+
}
361+
301362
private List<TeacherWorkspaceRepository.AttemptRow> loadAttempts(String classroomId, LocalDate from, LocalDate to) {
302363
if (from == null || to == null) {
303364
return repository.listAttemptsForClassroom(classroomId);

0 commit comments

Comments
 (0)