Skip to content
Open
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,23 @@ and implements it in Java.
[Releases](https://github.com/hei-school/hei-admin-api/releases) are published [here](https://gallery.ecr.aws/q6i6y5o4/hei-admin-api) as Docker images. Feel free to use them.

We welcome [contributions](https://github.com/hei-school/hei-admin-api/blob/dev/CONTRIBUTING.md).

## FEES_ONLY mode

Set the environment variable `FEES_ONLY=true` to restrict the API to a read-only subset of endpoints.
In this mode, only requests targeting the following URI prefixes are allowed:

| Prefix | Description |
|--------|-------------|
| `/fees` | Fee management |
| `/students` | Student management |
| `/whoami` | Current user identity |
| `/ping` | Liveness check |
| `/authentication` | Authentication |
| `/health` | Health checks |
| `/mpbs` | Mobile Payment by SMS |

All other endpoints return **HTTP 403 Forbidden** with the message
`"This endpoint is disabled in FEES_ONLY mode"`.

When `FEES_ONLY` is unset or `false`, the API behaves normally.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package school.hei.haapi.endpoint.rest.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
@Slf4j
public class FeesOnlyFilter extends OncePerRequestFilter {

private final boolean feesOnly;

public FeesOnlyFilter() {
this("true".equalsIgnoreCase(System.getenv("FEES_ONLY")));
}

public FeesOnlyFilter(boolean feesOnly) {
this.feesOnly = feesOnly;
}

private static final List<String> ALLOWED_PREFIXES =
List.of("/fees", "/students", "/whoami", "/ping", "/authentication", "/health", "/mpbs");

@Override
public void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

if (!feesOnly) {
filterChain.doFilter(request, response);
return;
}

String uri = request.getRequestURI();
boolean allowed = ALLOWED_PREFIXES.stream().anyMatch(uri::startsWith);

if (!allowed) {
log.info("FEES_ONLY mode: blocked request to {}", uri);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter().write("{\"message\": \"This endpoint is disabled in FEES_ONLY mode\"}");
return;
}

filterChain.doFilter(request, response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,22 @@ public class SecurityConf {
private final AbstractUserDetailsAuthenticationProvider authProvider;
private final HandlerExceptionResolver exceptionResolver;
private final CorRepository corRepository;
private final FeesOnlyFilter feesOnlyFilter;

public SecurityConf(
CasdoorAuthProvider authProvider,
// InternalToExternalErrorHandler behind
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver exceptionResolver,
CourseAssignmentService courseAssignmentService,
MonitoringStudentService monitoringStudentService,
CorRepository corRepository) {
CorRepository corRepository,
FeesOnlyFilter feesOnlyFilter) {
this.authProvider = authProvider;
this.exceptionResolver = exceptionResolver;
this.courseAssignmentService = courseAssignmentService;
this.monitoringStudentService = monitoringStudentService;
this.corRepository = corRepository;
this.feesOnlyFilter = feesOnlyFilter;
}

@Bean
Expand All @@ -77,10 +80,6 @@ public SecurityFilterChain configure(HttpSecurity httpSecurity) throws Exception
exceptionHandlingConfigurer ->
exceptionHandlingConfigurer
.authenticationEntryPoint(
// note(spring-exception)
// https://stackoverflow.com/questions/59417122/how-to-handle-usernamenotfoundexception-spring-security
// issues like when a user tries to access a resource
// without appropriate authentication elements
(req, res, e) ->
exceptionResolver.resolveException(
req, res, null, forbiddenWithRemoteInfo(req)))
Expand All @@ -92,6 +91,7 @@ req, res, null, forbiddenWithRemoteInfo(req))))

// authenticate
.authenticationProvider(authProvider)
.addFilterBefore(feesOnlyFilter, AnonymousAuthenticationFilter.class)
.addFilterBefore(
bearerFilter(
new OrRequestMatcher(
Expand Down Expand Up @@ -1096,11 +1096,6 @@ req, res, null, forbiddenWithRemoteInfo(req))))
.authenticated()
.requestMatchers("/**")
.denyAll())

// disable superfluous protections
// Eg if all clients are non-browser then no csrf
// https://docs.spring.io/spring-security/site/docs/3.2.0.CI-SNAPSHOT/reference/html/csrf.html,
// Sec 13.3
.cors(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
Expand All @@ -1125,11 +1120,6 @@ private BearerAuthFilter bearerFilter(RequestMatcher requiresAuthenticationReque
(httpServletRequest, httpServletResponse, authentication) -> {});
bearerFilter.setAuthenticationFailureHandler(
(req, res, e) ->
// note(spring-exception)
// issues like when a user is not found(i.e. UsernameNotFoundException)
// or other exceptions thrown inside authentication provider.
// In fact, this handles other authentication exceptions that are
// not handled by AccessDeniedException and AuthenticationEntryPoint
exceptionResolver.resolveException(req, res, null, forbiddenWithRemoteInfo(req)));
return bearerFilter;
}
Expand Down
91 changes: 91 additions & 0 deletions src/test/java/school/hei/haapi/unit/FeesOnlyFilterTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package school.hei.haapi.unit;

import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import java.io.IOException;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import school.hei.haapi.endpoint.rest.security.FeesOnlyFilter;

@ExtendWith(MockitoExtension.class)
class FeesOnlyFilterTest {

@Mock private FilterChain filterChain;

@Test
void fees_only_inactive_all_requests_pass() throws ServletException, IOException {
FeesOnlyFilter filter = new FeesOnlyFilter(false);

MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/teachers");
MockHttpServletResponse response = new MockHttpServletResponse();

filter.doFilterInternal(request, response, filterChain);

assertEquals(200, response.getStatus());
verify(filterChain).doFilter(request, response);
}

@Test
void fees_only_active_allows_configured_prefixes() throws ServletException, IOException {
FeesOnlyFilter filter = new FeesOnlyFilter(true);

List<String> allowedPrefixes =
List.of("/fees", "/students", "/whoami", "/ping", "/authentication", "/health", "/mpbs");

for (String prefix : allowedPrefixes) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI(prefix);
MockHttpServletResponse response = new MockHttpServletResponse();

filter.doFilterInternal(request, response, filterChain);

assertEquals(
200, response.getStatus(), "URI " + prefix + " should be allowed in FEES_ONLY mode");
}
}

@Test
void fees_only_active_blocks_other_uris() throws ServletException, IOException {
FeesOnlyFilter filter = new FeesOnlyFilter(true);

List<String> blockedUris =
List.of("/teachers", "/groups", "/events", "/courses", "/exams", "/unknown");

for (String uri : blockedUris) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI(uri);
MockHttpServletResponse response = new MockHttpServletResponse();

filter.doFilterInternal(request, response, filterChain);

assertEquals(SC_FORBIDDEN, response.getStatus(), "URI " + uri + " should be blocked");
assertEquals(
"{\"message\": \"This endpoint is disabled in FEES_ONLY mode\"}",
response.getContentAsString());
}
}

@Test
void fees_only_active_allows_subpaths_of_allowed_prefixes() throws ServletException, IOException {
FeesOnlyFilter filter = new FeesOnlyFilter(true);

MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/students/student1_id/fees/fee1_id/payments");
MockHttpServletResponse response = new MockHttpServletResponse();

filter.doFilterInternal(request, response, filterChain);

assertEquals(200, response.getStatus());
verify(filterChain).doFilter(request, response);
}
}