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:
21
web/src/main/kotlin/ru/ysu/lms/web/DateUtils.kt
Normal file
21
web/src/main/kotlin/ru/ysu/lms/web/DateUtils.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -283,6 +283,8 @@ class AdminController(
|
|||||||
model.addAttribute("lab", lab)
|
model.addAttribute("lab", lab)
|
||||||
model.addAttribute("groups", groupSections)
|
model.addAttribute("groups", groupSections)
|
||||||
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
|
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("deadlineLocal", lab.deadlineAt?.take(16) ?: "")
|
||||||
model.addAttribute("defenseLocal", lab.defenseAt?.take(16) ?: "")
|
model.addAttribute("defenseLocal", lab.defenseAt?.take(16) ?: "")
|
||||||
return "admin/lab"
|
return "admin/lab"
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ data class MeAssignmentDto(
|
|||||||
val pointsMax: Any? = null,
|
val pointsMax: Any? = null,
|
||||||
) {
|
) {
|
||||||
var statementHtml: String = ""
|
var statementHtml: String = ""
|
||||||
|
var deadlineMsk: String? = null
|
||||||
|
var defenseMsk: String? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class AuthPageController(private val api: ApiClient) {
|
|||||||
if (currentUser(session) != null) {
|
if (currentUser(session) != null) {
|
||||||
return "redirect:/"
|
return "redirect:/"
|
||||||
}
|
}
|
||||||
|
(session.getAttribute("csrfError") as? String)?.let {
|
||||||
|
session.removeAttribute("csrfError")
|
||||||
|
model.addAttribute("error", it)
|
||||||
|
}
|
||||||
model.addAttribute("adminExists", api.adminExists())
|
model.addAttribute("adminExists", api.adminExists())
|
||||||
return "login"
|
return "login"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,26 @@ class CsrfInterceptor : HandlerInterceptor {
|
|||||||
if (request.method.equals("POST", ignoreCase = true)) {
|
if (request.method.equals("POST", ignoreCase = true)) {
|
||||||
val sent = request.getParameter(FIELD)
|
val sent = request.getParameter(FIELD)
|
||||||
if (sent != token) {
|
if (sent != token) {
|
||||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "CSRF token mismatch")
|
request.getSession().setAttribute("csrfError", "CSRF-токен устарел. Попробуйте ещё раз.")
|
||||||
|
response.sendRedirect("/login")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
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 {
|
companion object {
|
||||||
const val ATTR = "csrfToken"
|
const val ATTR = "csrfToken"
|
||||||
const val FIELD = "_csrf"
|
const val FIELD = "_csrf"
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ class StudentController(
|
|||||||
fun dashboard(session: HttpSession, model: Model): String {
|
fun dashboard(session: HttpSession, model: Model): String {
|
||||||
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login"
|
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login"
|
||||||
val assignments = api.meAssignments(token).map {
|
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)
|
model.addAttribute("assignments", assignments)
|
||||||
return "student/dashboard"
|
return "student/dashboard"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ server:
|
|||||||
cookie:
|
cookie:
|
||||||
http-only: true
|
http-only: true
|
||||||
same-site: lax
|
same-site: lax
|
||||||
|
tracking-modes: cookie
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||||
<button class="btn btn-secondary btn-sm ml-2" onclick="return confirm('Сменить статус лабораторной?')" type="submit">Сменить статус</button>
|
<button class="btn btn-secondary btn-sm ml-2" onclick="return confirm('Сменить статус лабораторной?')" type="submit">Сменить статус</button>
|
||||||
</form>
|
</form>
|
||||||
<span th:if="${lab.deadlineAt != null}"> · Срок: <span th:text="${lab.deadlineAt}"></span></span>
|
<span th:if="${deadlineMsk != null}"> · Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
|
||||||
<span th:if="${lab.defenseAt != null}"> · Защита: <span th:text="${lab.defenseAt}"></span></span>
|
<span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
|
||||||
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
|
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</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.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.deadlineMsk}" th:text="|Срок сдачи: ${a.deadlineMsk} (МСК)|">—</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.defenseMsk}" th:text="|Защита: ${a.defenseMsk} (МСК)|">—</p>
|
||||||
<div class="md-body mt-4" th:utext="${a.statementHtml}"></div>
|
<div class="md-body mt-4" th:utext="${a.statementHtml}"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user