Moscow-time date display, CSRF error UX, session cookie hardening

- DateUtils: format ISO instants as dd.MM.yyyy HH:mm in Europe/Moscow
- admin/lab page and student dashboard show deadlines in MSK time
- CSRF failure redirects to /login with friendly message instead of 403
- session tracking-modes=cookie (no ;jsessionid in URLs) (previously set)
- web rebuild with all dialog/status-toggle fixes verified end-to-end
This commit is contained in:
2026-09-11 21:55:08 +03:00
parent 12383e7c39
commit 8336c5a9b2
9 changed files with 53 additions and 6 deletions

View File

@@ -0,0 +1,21 @@
package ru.ysu.lms.web
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
object DateUtils {
private val MSK = ZoneId.of("Europe/Moscow")
private val FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
fun formatMsk(value: String?): String? =
value?.let {
try {
val zdt = ZonedDateTime.ofInstant(Instant.parse(it), MSK)
zdt.format(FORMAT)
} catch (_: Exception) {
it
}
}
}

View File

@@ -283,6 +283,8 @@ class AdminController(
model.addAttribute("lab", lab)
model.addAttribute("groups", groupSections)
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
model.addAttribute("deadlineMsk", ru.ysu.lms.web.DateUtils.formatMsk(lab.deadlineAt))
model.addAttribute("defenseMsk", ru.ysu.lms.web.DateUtils.formatMsk(lab.defenseAt))
model.addAttribute("deadlineLocal", lab.deadlineAt?.take(16) ?: "")
model.addAttribute("defenseLocal", lab.defenseAt?.take(16) ?: "")
return "admin/lab"

View File

@@ -153,6 +153,8 @@ data class MeAssignmentDto(
val pointsMax: Any? = null,
) {
var statementHtml: String = ""
var deadlineMsk: String? = null
var defenseMsk: String? = null
}
@JsonIgnoreProperties(ignoreUnknown = true)

View File

@@ -27,6 +27,10 @@ class AuthPageController(private val api: ApiClient) {
if (currentUser(session) != null) {
return "redirect:/"
}
(session.getAttribute("csrfError") as? String)?.let {
session.removeAttribute("csrfError")
model.addAttribute("error", it)
}
model.addAttribute("adminExists", api.adminExists())
return "login"
}

View File

@@ -29,13 +29,26 @@ class CsrfInterceptor : HandlerInterceptor {
if (request.method.equals("POST", ignoreCase = true)) {
val sent = request.getParameter(FIELD)
if (sent != token) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "CSRF token mismatch")
request.getSession().setAttribute("csrfError", "CSRF-токен устарел. Попробуйте ещё раз.")
response.sendRedirect("/login")
return false
}
}
return true
}
override fun afterCompletion(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
ex: Exception?,
) {
// после успешного POST-запроса чистим флаг ошибки (если был)
if (request.method.equals("POST", ignoreCase = true)) {
request.getSession().removeAttribute("csrfError")
}
}
companion object {
const val ATTR = "csrfToken"
const val FIELD = "_csrf"

View File

@@ -21,7 +21,11 @@ class StudentController(
fun dashboard(session: HttpSession, model: Model): String {
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login"
val assignments = api.meAssignments(token).map {
it.apply { statementHtml = markdown.render(statementMd) }
it.apply {
statementHtml = markdown.render(statementMd)
deadlineMsk = ru.ysu.lms.web.DateUtils.formatMsk(deadlineAt)
defenseMsk = ru.ysu.lms.web.DateUtils.formatMsk(defenseAt)
}
}
model.addAttribute("assignments", assignments)
return "student/dashboard"

View File

@@ -5,6 +5,7 @@ server:
cookie:
http-only: true
same-site: lax
tracking-modes: cookie
spring:
application:

View File

@@ -13,8 +13,8 @@
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm ml-2" onclick="return confirm('Сменить статус лабораторной?')" type="submit">Сменить статус</button>
</form>
<span th:if="${lab.deadlineAt != null}"> · Срок: <span th:text="${lab.deadlineAt}"></span></span>
<span th:if="${lab.defenseAt != null}"> · Защита: <span th:text="${lab.defenseAt}"></span></span>
<span th:if="${deadlineMsk != null}"> · Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
<span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
</p>
</div>

View File

@@ -20,8 +20,8 @@
</div>
</div>
<p class="text-sm text-muted" th:if="${a.variantTitle}" th:text="${a.variantTitle}">—</p>
<p class="text-sm text-muted" th:if="${a.deadlineAt}" th:text="|Срок сдачи: ${a.deadlineAt}|">—</p>
<p class="text-sm text-muted" th:if="${a.defenseAt}" th:text="|Защита: ${a.defenseAt}|">—</p>
<p class="text-sm text-muted" th:if="${a.deadlineMsk}" th:text="|Срок сдачи: ${a.deadlineMsk} (МСК)|">—</p>
<p class="text-sm text-muted" th:if="${a.defenseMsk}" th:text="|Защита: ${a.defenseMsk} (МСК)|">—</p>
<div class="md-body mt-4" th:utext="${a.statementHtml}"></div>
</div>
</div>