Skip to content

Commit 59be6b2

Browse files
authored
Merge pull request #41 from 2RK-dev/feat/auth
Implement auth system
2 parents 9a38673 + 9a3ffb6 commit 59be6b2

37 files changed

Lines changed: 1203 additions & 37 deletions

.env.example

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1+
# --- Spring boot profile ---
2+
SPRING_PROFILE_ACTIVE= # active profile (e.g., dev, prod)
3+
14
# --- PostgreSQL ---
2-
POSTGRES_USER= # database username (e.g., user)
3-
POSTGRES_PASSWORD= # database password (e.g., password)
4-
POSTGRES_DB= # database name (e.g., mydb)
5-
POSTGRES_PORT= # database port (default 5432)
5+
POSTGRES_USER= # database username (e.g., user)
6+
POSTGRES_PASSWORD= # database password (e.g., password)
7+
POSTGRES_DB= # database name (e.g., mydb)
8+
POSTGRES_PORT= # database port (default 5432)
69

710
# --- Spring Boot Database ---
8-
SPRING_DB_URL= # JDBC URL to the DB container (e.g., jdbc:postgresql://postgres_db:5432/mydb)
9-
SPRING_DB_USERNAME= # username Spring Boot will use to connect to the DB
10-
SPRING_DB_PASSWORD= # password Spring Boot will use to connect to the DB
11-
APP_PORT= # port exposed for Spring Boot app (e.g., 8080)
11+
SPRING_DB_URL= # JDBC URL to the DB container (e.g., jdbc:postgresql://postgres_db:5432/mydb)
12+
SPRING_DB_USERNAME= # username Spring Boot will use to connect to the DB
13+
SPRING_DB_PASSWORD= # password Spring Boot will use to connect to the DB
14+
APP_PORT= # port exposed for Spring Boot app (e.g., 8080)
1215

1316
# --- JWT Configuration ---
14-
SPRING_JWT_SECRET_KEY= # secret key for JWT (replace in production)
15-
SPRING_JWT_EXPIRATION= # token validity duration in ms (e.g., 3600000 for 1h)
17+
SPRING_JWT_SECRET_KEY= # secret key for JWT (replace in production)
18+
SPRING_JWT_EXPIRATION= # token validity duration in seconds (e.g., 3600 for 1h)
19+
SPRING_REFRESH_SESSION_EXPIRATION= # refresh token validity duration in days
20+
SPRING_AUTH_COOKIE_SECURE= # set to true if using HTTPS

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
[[ -z "${{ secrets.APP_PORT }}" ]] && MISSING_SECRETS+=("APP_PORT")
2727
[[ -z "${{ secrets.SPRING_JWT_SECRET_KEY }}" ]] && MISSING_SECRETS+=("SPRING_JWT_SECRET_KEY")
2828
[[ -z "${{ secrets.SPRING_JWT_EXPIRATION }}" ]] && MISSING_SECRETS+=("SPRING_JWT_EXPIRATION")
29+
[[ -z "${{ secrets.SPRING_REFRESH_SESSION_EXPIRATION }}" ]] && MISSING_SECRETS+=("SPRING_REFRESH_SESSION_EXPIRATION")
2930
3031
# If any secrets are missing, print them and fail
3132
if [ ${#MISSING_SECRETS[@]} -ne 0 ]; then
@@ -54,6 +55,7 @@ jobs:
5455
APP_PORT=${{ secrets.APP_PORT }}
5556
SPRING_JWT_SECRET_KEY=${{ secrets.SPRING_JWT_SECRET_KEY }}
5657
SPRING_JWT_EXPIRATION=${{ secrets.SPRING_JWT_EXPIRATION }}
58+
SPRING_REFRESH_SESSION_EXPIRATION=${{ secrets.SPRING_REFRESH_SESSION_EXPIRATION }}
5759
EOF
5860
5961
- name: Set up JDK 21

build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,15 @@ dependencies {
5151
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.15.2'
5252
implementation 'org.apache.poi:poi-ooxml:5.4.0'
5353
implementation 'org.apache.commons:commons-csv:1.14.1'
54+
implementation 'org.springframework.boot:spring-boot-starter-security'
55+
implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
56+
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
57+
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'
5458
testImplementation 'org.testcontainers:junit-jupiter'
5559
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
5660
testImplementation 'org.testcontainers:postgresql'
61+
testImplementation 'org.springframework.security:spring-security-test'
62+
testImplementation 'org.apache.httpcomponents.client5:httpclient5'
5763

5864
compileOnly 'org.projectlombok:lombok'
5965
developmentOnly 'org.springframework.boot:spring-boot-devtools'

src/main/java/io/github/two_rk_dev/pointeurback/PointeurBackApplication.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package io.github.two_rk_dev.pointeurback;
22

3+
import io.github.two_rk_dev.pointeurback.config.AuthProperties;
4+
import io.github.two_rk_dev.pointeurback.config.CorsProperties;
35
import org.springframework.boot.SpringApplication;
46
import org.springframework.boot.autoconfigure.SpringBootApplication;
7+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
58

69
@SpringBootApplication
10+
@EnableConfigurationProperties({AuthProperties.class, CorsProperties.class})
711
public class PointeurBackApplication {
812

913
public static void main(String[] args) {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.github.two_rk_dev.pointeurback.config;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
5+
@ConfigurationProperties(prefix = "app.auth")
6+
public record AuthProperties(
7+
Long refreshSessionExpiration,
8+
Boolean cookieSecure,
9+
JwtProperties jwt
10+
) {
11+
public record JwtProperties(
12+
String secret,
13+
Long expiration
14+
) {
15+
}
16+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package io.github.two_rk_dev.pointeurback.config;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
5+
import java.util.List;
6+
7+
@ConfigurationProperties(prefix = "app.cors")
8+
public record CorsProperties(
9+
List<String> allowedOrigins
10+
) {
11+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package io.github.two_rk_dev.pointeurback.config;
2+
3+
import io.github.two_rk_dev.pointeurback.security.AppAuthenticationFilter;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.http.HttpMethod;
7+
import org.springframework.http.HttpStatus;
8+
import org.springframework.security.authentication.AuthenticationManager;
9+
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
10+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
11+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
12+
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
13+
import org.springframework.security.config.http.SessionCreationPolicy;
14+
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
15+
import org.springframework.security.crypto.password.PasswordEncoder;
16+
import org.springframework.security.web.SecurityFilterChain;
17+
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
18+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
19+
import org.springframework.web.cors.CorsConfiguration;
20+
21+
import java.util.List;
22+
23+
@Configuration
24+
@EnableWebSecurity
25+
class SecurityConfiguration {
26+
27+
@Bean
28+
public PasswordEncoder passwordEncoder() {
29+
return new BCryptPasswordEncoder();
30+
}
31+
32+
@Bean
33+
public SecurityFilterChain securityFilterChain(HttpSecurity http,
34+
AppAuthenticationFilter appAuthenticationFilter,
35+
CorsProperties corsProperties) throws Exception {
36+
http
37+
.csrf(AbstractHttpConfigurer::disable)
38+
.cors(cors -> cors.configurationSource(request -> {
39+
CorsConfiguration config = new CorsConfiguration();
40+
config.setAllowedOrigins(corsProperties.allowedOrigins());
41+
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
42+
config.setAllowedHeaders(List.of("*"));
43+
config.setAllowCredentials(true);
44+
return config;
45+
}))
46+
.anonymous(AbstractHttpConfigurer::disable)
47+
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
48+
.authorizeHttpRequests(auth -> auth
49+
.requestMatchers(
50+
"/auth/login",
51+
"/auth/refresh",
52+
"/auth/logout").permitAll()
53+
.requestMatchers(
54+
"/export/**",
55+
"/import/**",
56+
"/levels/**",
57+
"/rooms/**",
58+
"/teachers/**",
59+
"/teachingUnits/**").hasRole("ADMIN")
60+
.requestMatchers(HttpMethod.GET, "/schedule/**", "/auth/me").authenticated()
61+
.requestMatchers(HttpMethod.POST, "/schedule/**").hasRole("ADMIN")
62+
.requestMatchers(HttpMethod.PUT, "/schedule/**").hasRole("ADMIN")
63+
.requestMatchers(HttpMethod.DELETE, "/schedule/**").hasRole("ADMIN")
64+
.anyRequest().denyAll()
65+
)
66+
.exceptionHandling(exceptions -> exceptions
67+
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
68+
)
69+
.httpBasic(AbstractHttpConfigurer::disable)
70+
.formLogin(AbstractHttpConfigurer::disable)
71+
.logout(AbstractHttpConfigurer::disable)
72+
.addFilterBefore(appAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
73+
74+
return http.build();
75+
}
76+
77+
@Bean
78+
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
79+
return authConfig.getAuthenticationManager();
80+
}
81+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package io.github.two_rk_dev.pointeurback.controller;
2+
3+
import io.github.two_rk_dev.pointeurback.dto.LoggedInDTO;
4+
import io.github.two_rk_dev.pointeurback.dto.LoginRequestDTO;
5+
import io.github.two_rk_dev.pointeurback.dto.LoginResponseDTO;
6+
import io.github.two_rk_dev.pointeurback.dto.UserDTO;
7+
import io.github.two_rk_dev.pointeurback.service.AuthService;
8+
import jakarta.validation.Valid;
9+
import lombok.RequiredArgsConstructor;
10+
import org.jetbrains.annotations.NotNull;
11+
import org.springframework.http.HttpHeaders;
12+
import org.springframework.http.MediaType;
13+
import org.springframework.http.ResponseCookie;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.security.authentication.BadCredentialsException;
16+
import org.springframework.security.core.GrantedAuthority;
17+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
18+
import org.springframework.security.core.userdetails.UserDetails;
19+
import org.springframework.web.bind.annotation.*;
20+
21+
import java.util.List;
22+
23+
@RestController
24+
@RequestMapping("/auth")
25+
@RequiredArgsConstructor
26+
class AuthController {
27+
private final AuthService authService;
28+
29+
private static @NotNull String buildDeviceIdCookie(@NotNull LoggedInDTO loggedInDTO) {
30+
return ResponseCookie.from("device_id", loggedInDTO.refreshToken().deviceId())
31+
.sameSite("Strict")
32+
.path("/")
33+
.maxAge(Integer.MAX_VALUE)
34+
.httpOnly(true)
35+
.secure(loggedInDTO.refreshToken().secure())
36+
.build().toString();
37+
}
38+
39+
private static @NotNull String buildRefreshTokenCookie(@NotNull LoggedInDTO loggedInDTO) {
40+
return ResponseCookie.from("refresh_token", loggedInDTO.refreshToken().token())
41+
.sameSite("Strict")
42+
.path("/")
43+
.maxAge(loggedInDTO.refreshToken().maxAge())
44+
.httpOnly(true)
45+
.secure(loggedInDTO.refreshToken().secure())
46+
.build().toString();
47+
}
48+
49+
@PostMapping("/login")
50+
public ResponseEntity<LoginResponseDTO> login(
51+
@Valid @RequestBody LoginRequestDTO dto,
52+
@CookieValue(name = "device_id", required = false) String deviceId) {
53+
54+
LoggedInDTO loggedInDTO = authService.login(dto, deviceId);
55+
List<String> cookies = List.of(buildRefreshTokenCookie(loggedInDTO), buildDeviceIdCookie(loggedInDTO));
56+
return ResponseEntity.ok()
57+
.contentType(MediaType.APPLICATION_JSON)
58+
.headers(headers -> headers.addAll(HttpHeaders.SET_COOKIE, cookies))
59+
.body(loggedInDTO.responseDTO());
60+
}
61+
62+
@GetMapping("/me")
63+
public ResponseEntity<UserDTO> me(@AuthenticationPrincipal UserDetails userDetails) {
64+
String role = userDetails.getAuthorities().stream()
65+
.map(GrantedAuthority::getAuthority)
66+
.filter(authority -> authority.startsWith("ROLE_"))
67+
.map(authority -> authority.substring(5).toLowerCase())
68+
.findFirst().orElse(null);
69+
return ResponseEntity.ok(new UserDTO(userDetails.getUsername(), role));
70+
}
71+
72+
@PostMapping("/refresh")
73+
public ResponseEntity<LoginResponseDTO> refreshToken(
74+
@CookieValue(value = "device_id", required = false) String deviceId,
75+
@CookieValue(value = "refresh_token", required = false) String refreshToken) {
76+
77+
if (deviceId == null || refreshToken == null) throw new BadCredentialsException("Insufficient credentials");
78+
LoggedInDTO refreshedSession = authService.refreshSession(deviceId, refreshToken);
79+
return ResponseEntity.ok()
80+
.contentType(MediaType.APPLICATION_JSON)
81+
.headers(headers -> headers.set("Set-Cookie", buildRefreshTokenCookie(refreshedSession)))
82+
.body(refreshedSession.responseDTO());
83+
}
84+
85+
@PostMapping("/logout")
86+
public ResponseEntity<Void> logout(
87+
@CookieValue(value = "device_id", required = false) String deviceId,
88+
@CookieValue(value = "refresh_token", required = false) String refreshToken) {
89+
90+
if (deviceId == null || refreshToken == null) return ResponseEntity.noContent().build();
91+
authService.logout(deviceId, refreshToken);
92+
ResponseCookie cookie = ResponseCookie.from("refresh_token", "")
93+
.sameSite("Strict")
94+
.path("/")
95+
.maxAge(0)
96+
.httpOnly(true)
97+
.build();
98+
return ResponseEntity
99+
.noContent()
100+
.headers(headers -> headers.set("Set-Cookie", cookie.toString()))
101+
.build();
102+
}
103+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package io.github.two_rk_dev.pointeurback.dto;
2+
3+
public record LoggedInDTO(
4+
RefreshTokenDTO refreshToken,
5+
LoginResponseDTO responseDTO
6+
) {
7+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package io.github.two_rk_dev.pointeurback.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
public record LoginRequestDTO(
6+
@NotBlank String username,
7+
@NotBlank String password
8+
) {
9+
}

0 commit comments

Comments
 (0)