diff --git a/scripts/api_smoke_test.py b/scripts/api_smoke_test.py new file mode 100644 index 00000000..1dd2fc8e --- /dev/null +++ b/scripts/api_smoke_test.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +import os +import sys +from http import cookiejar +from urllib import request, parse + +BASE_URL = os.getenv("BASE_URL", "http://localhost:8585") +UID = os.getenv("UID", "toby") +PASSWORD = os.getenv("PASSWORD", "Admin14*&*41") + +COOKIE_JAR = cookiejar.CookieJar() +OPENER = request.build_opener(request.HTTPCookieProcessor(COOKIE_JAR)) + + +def http_request(method, url, data=None, headers=None): + if headers is None: + headers = {} + req_data = None + if data is not None: + req_data = data.encode("utf-8") + req = request.Request(url, data=req_data, headers=headers, method=method) + try: + with OPENER.open(req) as resp: + return resp.status, resp.read(), resp.headers + except Exception as exc: + if hasattr(exc, "code"): + return exc.code, b"", getattr(exc, "headers", {}) + raise + + +def print_status(label, method, path, json_body=None): + url = f"{BASE_URL}{path}" + headers = {} + data = None + if json_body is not None: + headers["Content-Type"] = "application/json" + data = json_body + status, body, resp_headers = http_request(method, url, data=data, headers=headers) + location = resp_headers.get("Location") if hasattr(resp_headers, "get") else None + if location: + print(f"{label} -> {status} (Location: {location})") + else: + print(f"{label} -> {status}") + if status >= 400 and body: + try: + text = body.decode("utf-8", errors="replace") + except Exception: + text = "" + print(" Error body:", text[:500]) + return status, body + + +def dump_cookies(): + if not COOKIE_JAR: + print("Cookies: ") + return + print("Cookies:") + for cookie in COOKIE_JAR: + print(f" {cookie.name}={cookie.value}; path={cookie.path}; secure={cookie.secure}") + + +def main(): + print("== Authenticate (JWT cookie) ==") + auth_body = '{"uid":"%s","password":"%s"}' % (UID, PASSWORD) + status, body = print_status("POST /authenticate", "POST", "/authenticate", json_body=auth_body) + if body: + try: + print("Auth response:", body.decode("utf-8")) + except Exception: + print("Auth response: ") + dump_cookies() + + print("\n== Person APIs (auth required) ==") + print_status("GET /api/person/get", "GET", "/api/person/get") + print_status("GET /api/people", "GET", "/api/people") + + print("\n== Admin-only check ==") + print_status("DELETE /api/person/6", "DELETE", "/api/person/6") + + print("\n== Analytics (auth required) ==") + print_status("GET /api/analytics/", "GET", "/api/analytics/") + + print("\n== Code Runner (auth required) ==") + print_status("GET /api/challenge-submission/my-submissions", "GET", "/api/challenge-submission/my-submissions") + + print("\n== Tinkle (auth required) ==") + print_status("GET /api/tinkle/all", "GET", "/api/tinkle/all") + + print("\n== Export/Import (admin only) ==") + print_status("GET /api/exports/getAll", "GET", "/api/exports/getAll") + print_status("GET /api/imports/backups", "GET", "/api/imports/backups") + + print("\nDone.") + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/src/main/java/com/open/spring/mvc/analytics/AnalyticsApiController.java b/src/main/java/com/open/spring/mvc/analytics/AnalyticsApiController.java index 0e65264a..8612989a 100644 --- a/src/main/java/com/open/spring/mvc/analytics/AnalyticsApiController.java +++ b/src/main/java/com/open/spring/mvc/analytics/AnalyticsApiController.java @@ -46,12 +46,75 @@ public class AnalyticsApiController { // Get all analytics records // Get all analytics records @GetMapping("/") - public ResponseEntity> getAllAnalytics() { + public ResponseEntity> getAllAnalytics() { List gradeList = gradeJpaRepository.findAll(); // Fetch all grade records from database if (gradeList.isEmpty()) { return new ResponseEntity<>(HttpStatus.NO_CONTENT); // No records found } - return new ResponseEntity<>(gradeList, HttpStatus.OK); // Return found records + + List dtoList = new ArrayList<>(); + for (SynergyGrade grade : gradeList) { + dtoList.add(AnalyticsGradeDto.from(grade)); + } + + return new ResponseEntity<>(dtoList, HttpStatus.OK); // Return found records + } + + public static class AnalyticsGradeDto { + private Long id; + private Double grade; + private Long assignmentId; + private String assignmentName; + private Long studentId; + private String studentUid; + private String studentName; + + public static AnalyticsGradeDto from(SynergyGrade grade) { + AnalyticsGradeDto dto = new AnalyticsGradeDto(); + if (grade == null) { + return dto; + } + dto.id = grade.getId(); + dto.grade = grade.getGrade(); + if (grade.getAssignment() != null) { + dto.assignmentId = grade.getAssignment().getId(); + dto.assignmentName = grade.getAssignment().getName(); + } + if (grade.getStudent() != null) { + dto.studentId = grade.getStudent().getId(); + dto.studentUid = grade.getStudent().getUid(); + dto.studentName = grade.getStudent().getName(); + } + return dto; + } + + public Long getId() { + return id; + } + + public Double getGrade() { + return grade; + } + + public Long getAssignmentId() { + return assignmentId; + } + + public String getAssignmentName() { + return assignmentName; + } + + public Long getStudentId() { + return studentId; + } + + public String getStudentUid() { + return studentUid; + } + + public String getStudentName() { + return studentName; + } } diff --git a/src/main/java/com/open/spring/mvc/assignments/Assignment.java b/src/main/java/com/open/spring/mvc/assignments/Assignment.java index 5a4e2956..7556d35d 100644 --- a/src/main/java/com/open/spring/mvc/assignments/Assignment.java +++ b/src/main/java/com/open/spring/mvc/assignments/Assignment.java @@ -70,6 +70,7 @@ public class Assignment { @OneToMany(mappedBy="assignment", cascade=CascadeType.ALL, orphanRemoval=true) + @JsonIgnore private List grades; @NotNull diff --git a/src/main/java/com/open/spring/mvc/person/Person.java b/src/main/java/com/open/spring/mvc/person/Person.java index ba37e34d..1b526e42 100644 --- a/src/main/java/com/open/spring/mvc/person/Person.java +++ b/src/main/java/com/open/spring/mvc/person/Person.java @@ -326,41 +326,43 @@ public int compareTo(Person other) { public static Person[] init() { ArrayList people = new ArrayList<>(); final Dotenv dotenv = Dotenv.load(); + + String defaultPassword = envOrDefault(dotenv, "DEFAULT_PASSWORD", "defaultPassword123"); // JSON-like list of person data using Map.ofEntries List> personData = Arrays.asList( // Admin user from .env Map.ofEntries( - Map.entry("name", dotenv.get("ADMIN_NAME")), - Map.entry("uid", dotenv.get("ADMIN_UID")), - Map.entry("email", dotenv.get("ADMIN_EMAIL")), - Map.entry("password", dotenv.get("ADMIN_PASSWORD")), - Map.entry("sid", dotenv.get("ADMIN_SID")), - Map.entry("pfp", dotenv.get("ADMIN_PFP")), + Map.entry("name", envOrDefault(dotenv, "ADMIN_NAME", "Admin User")), + Map.entry("uid", envOrDefault(dotenv, "ADMIN_UID", "admin")), + Map.entry("email", envOrDefault(dotenv, "ADMIN_EMAIL", "admin@example.com")), + Map.entry("password", envOrDefault(dotenv, "ADMIN_PASSWORD", defaultPassword)), + Map.entry("sid", envOrDefault(dotenv, "ADMIN_SID", "9999990")), + Map.entry("pfp", envOrDefault(dotenv, "ADMIN_PFP", "/images/default.png")), Map.entry("kasmServerNeeded", false), Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT", "ROLE_TEACHER", "ROLE_ADMIN")), Map.entry("stocks", "BTC,ETH") ), // Teacher user from .env Map.ofEntries( - Map.entry("name", dotenv.get("TEACHER_NAME")), - Map.entry("uid", dotenv.get("TEACHER_UID")), - Map.entry("email", dotenv.get("TEACHER_EMAIL")), - Map.entry("password", dotenv.get("TEACHER_PASSWORD")), - Map.entry("sid", dotenv.get("TEACHER_SID")), - Map.entry("pfp", dotenv.get("TEACHER_PFP")), + Map.entry("name", envOrDefault(dotenv, "TEACHER_NAME", "Teacher User")), + Map.entry("uid", envOrDefault(dotenv, "TEACHER_UID", "teacher")), + Map.entry("email", envOrDefault(dotenv, "TEACHER_EMAIL", "teacher@example.com")), + Map.entry("password", envOrDefault(dotenv, "TEACHER_PASSWORD", defaultPassword)), + Map.entry("sid", envOrDefault(dotenv, "TEACHER_SID", "9999998")), + Map.entry("pfp", envOrDefault(dotenv, "TEACHER_PFP", "/images/default.png")), Map.entry("kasmServerNeeded", true), Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_TEACHER")), Map.entry("stocks", "BTC,ETH") ), // Default user from .env Map.ofEntries( - Map.entry("name", dotenv.get("USER_NAME")), - Map.entry("uid", dotenv.get("USER_UID")), - Map.entry("email", dotenv.get("USER_EMAIL")), - Map.entry("password", dotenv.get("USER_PASSWORD")), - Map.entry("sid", dotenv.get("USER_SID")), - Map.entry("pfp", dotenv.get("USER_PFP")), + Map.entry("name", envOrDefault(dotenv, "USER_NAME", "Default User")), + Map.entry("uid", envOrDefault(dotenv, "USER_UID", "user")), + Map.entry("email", envOrDefault(dotenv, "USER_EMAIL", "user@example.com")), + Map.entry("password", envOrDefault(dotenv, "USER_PASSWORD", defaultPassword)), + Map.entry("sid", envOrDefault(dotenv, "USER_SID", "9999999")), + Map.entry("pfp", envOrDefault(dotenv, "USER_PFP", "/images/default.png")), Map.entry("kasmServerNeeded", true), Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT")), Map.entry("stocks", "BTC,ETH") @@ -370,7 +372,7 @@ public static Person[] init() { Map.entry("name", "Alexander Graham Bell"), Map.entry("uid", "lex"), Map.entry("email", "lexb@gmail.com"), - Map.entry("password", dotenv.get("DEFAULT_PASSWORD")), + Map.entry("password", defaultPassword), Map.entry("sid", "9999991"), Map.entry("pfp", "/images/lex.png"), Map.entry("kasmServerNeeded", false), @@ -382,7 +384,7 @@ public static Person[] init() { Map.entry("name", "Madam Curie"), Map.entry("uid", "madam"), Map.entry("email", "madam@gmail.com"), - Map.entry("password", dotenv.get("DEFAULT_PASSWORD")), + Map.entry("password", defaultPassword), Map.entry("sid", "9999992"), Map.entry("pfp", "/images/madam.png"), Map.entry("kasmServerNeeded", false), @@ -391,11 +393,11 @@ public static Person[] init() { ), // My user - from .env Map.ofEntries( - Map.entry("name", dotenv.get("MY_NAME")), - Map.entry("uid", dotenv.get("MY_UID")), - Map.entry("email", dotenv.get("MY_EMAIL")), - Map.entry("password", dotenv.get("DEFAULT_PASSWORD")), - Map.entry("sid", dotenv.get("MY_SID") != null ? dotenv.get("MY_SID") : "9999993"), + Map.entry("name", envOrDefault(dotenv, "MY_NAME", "My User")), + Map.entry("uid", envOrDefault(dotenv, "MY_UID", "myuser")), + Map.entry("email", envOrDefault(dotenv, "MY_EMAIL", "myuser@example.com")), + Map.entry("password", defaultPassword), + Map.entry("sid", envOrDefault(dotenv, "MY_SID", "9999993")), Map.entry("pfp", "/images/default.png"), Map.entry("kasmServerNeeded", true), Map.entry("roles", Arrays.asList("ROLE_USER", "ROLE_STUDENT", "ROLE_TEACHER", "ROLE_ADMIN")), @@ -406,7 +408,7 @@ public static Person[] init() { Map.entry("name", "Alan Turing"), Map.entry("uid", "alan"), Map.entry("email", "turing@gmail.com"), - Map.entry("password", dotenv.get("DEFAULT_PASSWORD")), + Map.entry("password", defaultPassword), Map.entry("sid", "9999994"), Map.entry("pfp", "/images/alan.png"), Map.entry("kasmServerNeeded", false), @@ -450,6 +452,14 @@ public static Person[] init() { return people.toArray(new Person[0]); } + private static String envOrDefault(Dotenv dotenv, String key, String defaultValue) { + String value = dotenv.get(key); + if (value == null || value.isBlank()) { + return defaultValue; + } + return value; + } + ////////////////////////////////////////////////////////////////////////////////// /// override toString() method diff --git a/src/main/java/com/open/spring/security/JwtApiController.java b/src/main/java/com/open/spring/security/JwtApiController.java index 7bfbf2fc..56d0e249 100644 --- a/src/main/java/com/open/spring/security/JwtApiController.java +++ b/src/main/java/com/open/spring/security/JwtApiController.java @@ -24,6 +24,7 @@ import com.open.spring.mvc.person.Person; import com.open.spring.mvc.person.PersonDetailsService; +import com.open.spring.mvc.person.PersonJpaRepository; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -41,6 +42,9 @@ public class JwtApiController { @Autowired private PersonDetailsService personDetailsService; + @Autowired + private PersonJpaRepository personJpaRepository; + @Value("${jwt.cookie.secure:true}") // Defaults to production setting if property not found private boolean cookieSecure; @@ -50,16 +54,23 @@ public class JwtApiController { @Value("${jwt.cookie.max-age:43200}") // 12 hours private long cookieMaxAge; + @Value("${server.servlet.session.cookie.name:sess_java_spring}") + private String sessionCookieName; + @PostMapping("/authenticate") - public ResponseEntity createAuthenticationToken(@RequestBody Person authenticationRequest) throws Exception { + public ResponseEntity createAuthenticationToken(@RequestBody Person authenticationRequest, HttpServletRequest request) throws Exception { + String resolvedUid = resolveUid(authenticationRequest); + if (resolvedUid == null) { + return new ResponseEntity<>("Authentication failed: INVALID_CREDENTIALS", HttpStatus.UNAUTHORIZED); + } try { - authenticate(authenticationRequest.getUid(), authenticationRequest.getPassword()); + authenticate(resolvedUid, authenticationRequest.getPassword()); } catch (Exception e) { return new ResponseEntity<>("Authentication failed: " + e.getMessage(), HttpStatus.UNAUTHORIZED); } final UserDetails userDetails = personDetailsService - .loadUserByUsername(authenticationRequest.getUid()); + .loadUserByUsername(resolvedUid); // Get the roles of the user List roles = userDetails.getAuthorities().stream() @@ -73,18 +84,40 @@ public ResponseEntity createAuthenticationToken(@RequestBody Person authentic return new ResponseEntity<>("Token generation failed", HttpStatus.INTERNAL_SERVER_ERROR); } + boolean secureFlag = cookieSecure && request.isSecure(); + String sameSite = secureFlag ? cookieSameSite : "Lax"; // Build cookie with development-friendly settings // For localhost: allow HTTP and SameSite=Lax // For production: require HTTPS and SameSite=None; Secure ResponseCookie tokenCookie = ResponseCookie.from("jwt_java_spring", token) - .httpOnly(false) - .secure(cookieSecure) // Configured via jwt.cookie.secure in application.properties - .path("/") + .httpOnly(true) + .secure(secureFlag) + .path("/api") .maxAge(cookieMaxAge) // Configured via jwt.cookie.max-age in application.properties - .sameSite(cookieSameSite) // Configured via jwt.cookie.same-site in application.properties + .sameSite(sameSite) .build(); - return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, tokenCookie.toString()).body(authenticationRequest.getUid() + " was authenticated successfully"); + return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, tokenCookie.toString()).body(resolvedUid + " was authenticated successfully"); + } + + private String resolveUid(Person authenticationRequest) { + if (authenticationRequest == null) { + return null; + } + String uid = authenticationRequest.getUid(); + if (uid != null && !uid.isBlank()) { + if (uid.contains("@")) { + Person person = personJpaRepository.findByEmail(uid); + return person != null ? person.getUid() : null; + } + return uid; + } + String email = authenticationRequest.getEmail(); + if (email != null && !email.isBlank()) { + Person person = personJpaRepository.findByEmail(email); + return person != null ? person.getUid() : null; + } + return null; } private void authenticate(String username, String password) throws Exception { @@ -107,17 +140,30 @@ public class CustomLogoutController { public String performLogout(Authentication authentication, HttpServletRequest request, HttpServletResponse response) { // Perform logout using SecurityContextLogoutHandler logoutHandler.logout(request, response, authentication); + + boolean secureFlag = cookieSecure && request.isSecure(); + String sameSite = secureFlag ? cookieSameSite : "Lax"; // Expire the JWT token immediately by setting a past expiration date - ResponseCookie cookie = ResponseCookie.from("jwt_java_spring", "") - .secure(cookieSecure) // Configured via jwt.cookie.secure in application.properties - .path("/") - .maxAge(0) // Set maxAge to 0 to expire the cookie immediately - .sameSite(cookieSameSite) // Configured via jwt.cookie.same-site in application.properties - .build(); + ResponseCookie jwtCookie = ResponseCookie.from("jwt_java_spring", "") + .httpOnly(true) + .secure(secureFlag) + .path("/api") + .maxAge(0) // Set maxAge to 0 to expire the cookie immediately + .sameSite(sameSite) + .build(); + + ResponseCookie sessionCookie = ResponseCookie.from(sessionCookieName, "") + .httpOnly(true) + .secure(secureFlag) + .path("/") + .maxAge(0) + .sameSite(sameSite) + .build(); - // Set the cookie in the response to effectively "remove" the JWT - response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + // Set the cookies in the response to effectively "remove" them + response.addHeader(HttpHeaders.SET_COOKIE, jwtCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, sessionCookie.toString()); // Optional: You can also clear the "Authorization" header if needed response.setHeader("Authorization", null); diff --git a/src/main/java/com/open/spring/security/JwtRequestFilter.java b/src/main/java/com/open/spring/security/JwtRequestFilter.java index a97e41c6..a2e0d32e 100644 --- a/src/main/java/com/open/spring/security/JwtRequestFilter.java +++ b/src/main/java/com/open/spring/security/JwtRequestFilter.java @@ -105,18 +105,15 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht return; } - String origin = request.getHeader("X-Origin"); - - // If the request is coming from the client api - if (origin != null && origin.equals("client")) { - logger.warn("Client request: " + buildRequestLogMessage(request)); + String requestUri = request.getRequestURI(); + if (requestUri != null && requestUri.startsWith("/api/")) { + logger.warn("API request: " + buildRequestLogMessage(request)); handleClientRequest(request, response, chain); - // Else the request is coming from session - } else { - logger.warn("Session request: " + buildRequestLogMessage(request)); - chain.doFilter(request, response); return; } + + logger.warn("Session request: " + buildRequestLogMessage(request)); + chain.doFilter(request, response); } /** diff --git a/src/main/java/com/open/spring/security/MvcSecurityConfig.java b/src/main/java/com/open/spring/security/MvcSecurityConfig.java index 56a64395..458c0fbd 100644 --- a/src/main/java/com/open/spring/security/MvcSecurityConfig.java +++ b/src/main/java/com/open/spring/security/MvcSecurityConfig.java @@ -1,11 +1,21 @@ package com.open.spring.security; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseCookie; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.SecurityFilterChain; /* @@ -32,6 +42,18 @@ @Configuration public class MvcSecurityConfig { + @Value("${jwt.cookie.secure:true}") + private boolean cookieSecure; + + @Value("${jwt.cookie.same-site:None}") + private String cookieSameSite; + + @Value("${server.servlet.session.cookie.name:sess_java_spring}") + private String sessionCookieName; + + @Autowired + private JwtTokenUtil jwtTokenUtil; + /** * MVC security: form login, session-based. */ @@ -50,9 +72,9 @@ public SecurityFilterChain mvcSecurityFilterChain(HttpSecurity http) throws Exce .requestMatchers("/mvc/person/reset/**").permitAll() .requestMatchers("/mvc/person/read/**").authenticated() .requestMatchers("/mvc/person/cookie-clicker").authenticated() - .requestMatchers(HttpMethod.GET,"/mvc/person/update/user").authenticated() - .requestMatchers(HttpMethod.GET,"/mvc/person/update/**").authenticated() - .requestMatchers(HttpMethod.POST,"/mvc/person/update/").authenticated() + .requestMatchers(HttpMethod.GET,"/mvc/person/update/user").hasAuthority("ROLE_ADMIN") + .requestMatchers(HttpMethod.GET,"/mvc/person/update/**").hasAuthority("ROLE_ADMIN") + .requestMatchers(HttpMethod.POST,"/mvc/person/update/").hasAuthority("ROLE_ADMIN") .requestMatchers(HttpMethod.POST,"/mvc/person/update/role").hasAuthority("ROLE_ADMIN") .requestMatchers(HttpMethod.POST,"/mvc/person/update/roles").hasAuthority("ROLE_ADMIN") .requestMatchers("/mvc/person/delete/**").hasAuthority("ROLE_ADMIN") @@ -73,16 +95,64 @@ public SecurityFilterChain mvcSecurityFilterChain(HttpSecurity http) throws Exce .requestMatchers("/mvc/assignments/read").hasAnyAuthority("ROLE_ADMIN", "ROLE_TEACHER") .requestMatchers("/mvc/bank/read").hasAuthority("ROLE_ADMIN") .requestMatchers("/mvc/progress/read").hasAnyAuthority("ROLE_ADMIN", "ROLE_TEACHER") - - // Fallback --------------------------------------------------- - .requestMatchers("/**").permitAll() + .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") - .defaultSuccessUrl("/mvc/person/read")) + .successHandler((request, response, authentication) -> { + if (authentication == null || !authentication.isAuthenticated()) { + response.sendRedirect("/login?error"); + return; + } + + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + List roles = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + + String token = jwtTokenUtil.generateToken(userDetails, roles); + if (token == null) { + response.sendError(500, "Token generation failed"); + return; + } + + boolean secureFlag = cookieSecure && request.isSecure(); + String sameSite = secureFlag ? cookieSameSite : "Lax"; + ResponseCookie jwtCookie = ResponseCookie.from("jwt_java_spring", token) + .httpOnly(true) + .secure(secureFlag) + .path("/api") + .maxAge(-1) + .sameSite(sameSite) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, jwtCookie.toString()); + response.sendRedirect("/mvc/person/read"); + })) .logout(logout -> logout - .deleteCookies("sess_java_spring") - .logoutSuccessUrl("/")); + .invalidateHttpSession(true) + .clearAuthentication(true) + .logoutSuccessHandler((request, response, authentication) -> { + boolean secureFlag = cookieSecure && request.isSecure(); + String sameSite = secureFlag ? cookieSameSite : "Lax"; + ResponseCookie sessionCookie = ResponseCookie.from(sessionCookieName, "") + .httpOnly(true) + .secure(secureFlag) + .path("/") + .maxAge(0) + .sameSite(sameSite) + .build(); + ResponseCookie jwtCookie = ResponseCookie.from("jwt_java_spring", "") + .httpOnly(true) + .secure(secureFlag) + .path("/api") + .maxAge(0) + .sameSite(sameSite) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, sessionCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, jwtCookie.toString()); + response.sendRedirect("/login?logout"); + })); return http.build(); } diff --git a/src/main/java/com/open/spring/security/SecurityConfig.java b/src/main/java/com/open/spring/security/SecurityConfig.java index c0b5b20a..568abd4c 100644 --- a/src/main/java/com/open/spring/security/SecurityConfig.java +++ b/src/main/java/com/open/spring/security/SecurityConfig.java @@ -8,6 +8,7 @@ import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; @@ -33,13 +34,12 @@ * * Endpoint Access Levels: * - permitAll(): Anyone can access (e.g., /authenticate, /api/person/create) - * - authenticated(): Requires valid JWT token (e.g., /api/people/**, /api/assets/**) + * - hasAnyAuthority(...): Requires one of the specified roles (e.g., /api/people/**) * - hasAuthority("ROLE_ADMIN"): Requires admin role (e.g., DELETE /api/person/**) * - hasAnyAuthority(...): Requires one of the specified roles (e.g., /api/synergy/**) * * IMPORTANT: - * - Always set authentication endpoints to permitAll() so users can login without being logged in - * - Always set account creation endpoints to permitAll() so users can create accounts + * - Keep authentication + account creation endpoints permitAll() * - For MVC endpoint security (form-based login), see MvcSecurityConfig.java * * Filter Chain Order: @@ -72,11 +72,11 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce // JWT related configuration .csrf(csrf -> csrf.disable()) - // .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) OBSOLETE, OVERWRITTEN BY BELOW + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth // ========== AUTHENTICATION & USER MANAGEMENT ========== - // Public endpoints - no authentication required, support user login and account creation + // Public endpoint - no authentication required, supports user login .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // Allow CORS preflight requests .requestMatchers(HttpMethod.POST, "/authenticate").permitAll() .requestMatchers(HttpMethod.POST, "/api/person/create").permitAll() @@ -88,15 +88,15 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce // All other /api/person/** and /api/people/** operations handled by default rule // ====================================================== - // ========== PUBLIC API ENDPOINTS ========== - // Intentionally public - used for polling and public features - .requestMatchers("/api/jokes/**").permitAll() + // ========== API ENDPOINTS (REQUIRE AT LEAST USER) ========== + // Previously public endpoints now require authenticated roles + .requestMatchers("/api/jokes/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") // Pause Menu APIs should be public - .requestMatchers("/api/pausemenu/**").permitAll() + .requestMatchers("/api/pausemenu/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") // Leaderboard should be public - displays scores without authentication - .requestMatchers("/api/leaderboard/**").permitAll() + .requestMatchers("/api/leaderboard/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") // Frontend calls gamer score endpoint; make it public - .requestMatchers("/api/gamer/**").permitAll() + .requestMatchers("/api/gamer/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") // ========================================== .requestMatchers("/api/exports/**").hasAuthority("ROLE_ADMIN") .requestMatchers("/api/imports/**").hasAuthority("ROLE_ADMIN") @@ -110,11 +110,11 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce .requestMatchers(HttpMethod.DELETE, "/api/synergy/saigai/").hasAnyAuthority("ROLE_STUDENT", "ROLE_TEACHER", "ROLE_ADMIN") // Teacher and admin access for other POST operations .requestMatchers(HttpMethod.POST, "/api/synergy/**").hasAnyAuthority("ROLE_TEACHER", "ROLE_ADMIN") - // Allow unauthenticated frontend/client requests to the AI preferences endpoint - .requestMatchers(HttpMethod.POST, "/api/upai").permitAll() - .requestMatchers(HttpMethod.GET, "/api/upai/**").permitAll() - .requestMatchers(HttpMethod.POST, "/api/gemini-frq/grade").permitAll() - .requestMatchers(HttpMethod.GET, "/api/gemini-frq/grade/**").permitAll() + // AI preferences endpoints require authenticated roles + .requestMatchers(HttpMethod.POST, "/api/upai").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") + .requestMatchers(HttpMethod.GET, "/api/upai/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") + .requestMatchers(HttpMethod.POST, "/api/gemini-frq/grade").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") + .requestMatchers(HttpMethod.GET, "/api/gemini-frq/grade/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") // Admin access for certificates + quests .requestMatchers(HttpMethod.POST, "/api/quests/**").hasAnyAuthority("ROLE_TEACHER", "ROLE_ADMIN") .requestMatchers(HttpMethod.PUT, "/api/quests/**").hasAnyAuthority("ROLE_TEACHER", "ROLE_ADMIN") @@ -128,34 +128,34 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce .requestMatchers(HttpMethod.DELETE, "/api/user-certificates/**").hasAnyAuthority("ROLE_TEACHER", "ROLE_ADMIN") // ================================================= - // ========== PUBLIC API ENDPOINTS (Legacy - TODO: Review for security) ========== - // These endpoints are currently wide open - consider if they should require authentication - .requestMatchers("/api/analytics/**").permitAll() - .requestMatchers("/api/plant/**").permitAll() - .requestMatchers("/api/groups/**").permitAll() - .requestMatchers("/api/grade-prediction/**").permitAll() - .requestMatchers("/api/admin-evaluation/**").permitAll() - .requestMatchers("/api/grades/**").permitAll() - .requestMatchers("/api/progress/**").permitAll() - .requestMatchers("/api/calendar/**").permitAll() - // Sprint dates - GET is public, POST/PUT/DELETE require auth - .requestMatchers(HttpMethod.GET, "/api/sprint-dates/**").permitAll() + // ========== LEGACY API ENDPOINTS (NOW AUTHENTICATED) ========== + // These endpoints now require authenticated roles + .requestMatchers("/api/analytics/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") + .requestMatchers("/api/plant/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") + .requestMatchers("/api/groups/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") + .requestMatchers("/api/grade-prediction/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") + .requestMatchers("/api/admin-evaluation/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") + .requestMatchers("/api/grades/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") + .requestMatchers("/api/progress/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") + .requestMatchers("/api/calendar/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") + // Sprint dates - GET requires authenticated roles + .requestMatchers(HttpMethod.GET, "/api/sprint-dates/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") // User preferences - requires authentication (handled by default rule) // ================================================================================ // ========== CHALLENGE SUBMISSION ========== // Code runner challenge submissions - requires authentication - .requestMatchers(HttpMethod.POST, "/api/challenge-submission/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/challenge-submission/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") // ========================================== // ========== OCS ANALYTICS ========== // OCS Analytics endpoints - require authentication to associate data with user - .requestMatchers("/api/ocs-analytics/**").authenticated() + .requestMatchers("/api/ocs-analytics/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") // =================================== // ========== DEFAULT: ALL OTHER API ENDPOINTS ========== // Secure by default - any endpoint not explicitly listed above requires authentication - .requestMatchers("/api/**").authenticated() + .requestMatchers("/api/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "ROLE_TEACHER", "ROLE_STUDENT") // ====================================================== ) diff --git a/src/main/java/com/open/spring/system/ModelInit.java b/src/main/java/com/open/spring/system/ModelInit.java index 8c30bb52..e8df77e9 100644 --- a/src/main/java/com/open/spring/system/ModelInit.java +++ b/src/main/java/com/open/spring/system/ModelInit.java @@ -435,6 +435,11 @@ CommandLineRunner run() { Assignment assignment = assignmentJpaRepository.findByName(gradeInfo[1]); Person student = personJpaRepository.findByUid(gradeInfo[2]); + if (assignment == null || student == null) { + System.out.println("Skipping SynergyGrade seed: missing assignment or student for " + gradeInfo[1] + " / " + gradeInfo[2]); + continue; + } + SynergyGrade gradeFound = gradeJpaRepository.findByAssignmentAndStudent(assignment, student); if (gradeFound == null) { // If the grade doesn't exist SynergyGrade newGrade = new SynergyGrade(gradeValue, assignment, student); diff --git a/src/main/resources/templates/person/read.html b/src/main/resources/templates/person/read.html index 8635ba4b..e7d1e5c9 100644 --- a/src/main/resources/templates/person/read.html +++ b/src/main/resources/templates/person/read.html @@ -80,9 +80,10 @@

Person Viewer