Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.ericbouchut.learndev.common.config;

import jakarta.servlet.DispatcherType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
Expand All @@ -10,6 +12,8 @@

@Configuration
@EnableWebSecurity
// Enables @PreAuthorize for service-level rules (e.g. course ownership).
@EnableMethodSecurity
public class SecurityConfig {

@Bean
Expand All @@ -21,7 +25,13 @@ public PasswordEncoder passwordEncoder() {
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// The ERROR dispatch renders templates/error/*.html for a
// response already authorized (or denied) on its way in.
.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
.requestMatchers("/", "/privacy", "/auth/**", "/css/**", "/js/**", "/fonts/**").permitAll()
.requestMatchers("/instructor/**").hasRole("INSTRUCTOR")
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/courses/**").authenticated()
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/auth/login") // GET: show the login form
Expand Down
17 changes: 17 additions & 0 deletions src/main/resources/templates/error/403.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('Access denied — learn-dev')}"></head>
<body>
<th:block th:replace="~{fragments/layout :: header(null)}"></th:block>

<main class="site-main site-main--narrow" id="main" tabindex="-1">
<h1>Access denied</h1>
<p>You do not have permission to view this page.</p>
<p>
<a class="button button--primary" th:href="@{/dashboard}">Back to your dashboard</a>
</p>
</main>

<th:block th:replace="~{fragments/layout :: footer}"></th:block>
</body>
</html>
17 changes: 17 additions & 0 deletions src/main/resources/templates/error/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('Page not found — learn-dev')}"></head>
<body>
<th:block th:replace="~{fragments/layout :: header(null)}"></th:block>

<main class="site-main site-main--narrow" id="main" tabindex="-1">
<h1>Page not found</h1>
<p>The page you are looking for does not exist or has moved.</p>
<p>
<a class="button button--primary" th:href="@{/}">Back to the home page</a>
</p>
</main>

<th:block th:replace="~{fragments/layout :: footer}"></th:block>
</body>
</html>
17 changes: 17 additions & 0 deletions src/main/resources/templates/error/500.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('Something went wrong — learn-dev')}"></head>
<body>
<th:block th:replace="~{fragments/layout :: header(null)}"></th:block>

<main class="site-main site-main--narrow" id="main" tabindex="-1">
<h1>Something went wrong</h1>
<p>An unexpected error occurred on our side. Please try again in a moment.</p>
<p>
<a class="button button--primary" th:href="@{/}">Back to the home page</a>
</p>
</main>

<th:block th:replace="~{fragments/layout :: footer}"></th:block>
</body>
</html>
9 changes: 8 additions & 1 deletion src/main/resources/templates/fragments/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
- head(title): document head with the design stylesheets
- header(current): skip link + banner + auth-aware navigation;
`current` names the nav entry of the calling page ('home', 'login',
'register', 'dashboard' or null) and drives aria-current="page"
'register', 'dashboard', 'instructor', 'admin' or null) and drives
aria-current="page"
- footer: site footer
Each page keeps its own <html lang> (privacy.html is lang="fr").
-->
Expand Down Expand Up @@ -38,6 +39,12 @@
<ul class="nav__list" sec:authorize="isAuthenticated()">
<li><a class="nav__link" th:href="@{/dashboard}"
th:attr="aria-current=${current == 'dashboard'} ? 'page' : null">Dashboard</a></li>
<li sec:authorize="hasRole('INSTRUCTOR')">
<a class="nav__link" th:href="@{/instructor/courses}"
th:attr="aria-current=${current == 'instructor'} ? 'page' : null">Instructor</a></li>
<li sec:authorize="hasRole('ADMIN')">
<a class="nav__link" th:href="@{/admin/users}"
th:attr="aria-current=${current == 'admin'} ? 'page' : null">Admin</a></li>
<li>
<form th:action="@{/auth/logout}" method="post">
<button class="button button--ghost" type="submit">Log out</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.ericbouchut.learndev.common.config;

import com.ericbouchut.learndev.support.AbstractPostgresIT;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
* Access matrix for the role-gated URL prefixes: anonymous users are sent to
* the login page, the wrong role is denied (403), and the right role passes
* the gate. The instructor and admin controllers do not exist yet, so "passes
* the gate" is asserted as 404 (the request reached MVC dispatch); tighten
* those assertions to 200 as each feature phase lands.
*
* <p>Named with the {@code Test} suffix (not {@code IT}) so Surefire runs it
* as part of {@code mvn test}; this project does not use the Failsafe plugin.
*/
@SpringBootTest(properties = {
// This feature does not use MongoDB; keep the test context Postgres-only.
"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration,"
+ "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration"
})
@AutoConfigureMockMvc
class SecurityMatrixTest extends AbstractPostgresIT {

@Autowired
MockMvc mvc;

// Anonymous: every gated prefix redirects to the login page.

@Test
void anonymous_is_redirected_to_login_on_gated_prefixes() throws Exception {
for (String path : new String[] {"/courses", "/instructor/courses", "/admin/users"}) {
mvc.perform(get(path))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("**/auth/login"));
}
}

// Student: may pass the /courses gate, denied on instructor and admin.

@Test
@WithMockUser(roles = "STUDENT")
void student_passes_courses_gate() throws Exception {
mvc.perform(get("/courses")).andExpect(status().isNotFound());
}

@Test
@WithMockUser(roles = "STUDENT")
void student_is_denied_instructor_and_admin() throws Exception {
mvc.perform(get("/instructor/courses")).andExpect(status().isForbidden());
mvc.perform(get("/admin/users")).andExpect(status().isForbidden());
}

// Instructor: may pass the /instructor gate, denied on admin.

@Test
@WithMockUser(roles = "INSTRUCTOR")
void instructor_passes_instructor_gate() throws Exception {
mvc.perform(get("/instructor/courses")).andExpect(status().isNotFound());
}

@Test
@WithMockUser(roles = "INSTRUCTOR")
void instructor_is_denied_admin() throws Exception {
mvc.perform(get("/admin/users")).andExpect(status().isForbidden());
}

// Admin: may pass the /admin gate; not an instructor, so denied there.

@Test
@WithMockUser(roles = "ADMIN")
void admin_passes_admin_gate() throws Exception {
mvc.perform(get("/admin/users")).andExpect(status().isNotFound());
}

@Test
@WithMockUser(roles = "ADMIN")
void admin_is_denied_instructor() throws Exception {
mvc.perform(get("/instructor/courses")).andExpect(status().isForbidden());
}
}