diff --git a/build.gradle b/build.gradle index 3c8a390..fc174ab 100644 --- a/build.gradle +++ b/build.gradle @@ -3,14 +3,11 @@ import org.openapitools.generator.gradle.plugin.tasks.GenerateTask plugins { id 'java' - id 'org.springframework.boot' version '3.2.2' + id 'org.springframework.boot' version '3.2.2' id 'io.spring.dependency-management' version '1.1.3' id 'org.asciidoctor.jvm.convert' version '4.0.5' id 'org.openapi.generator' version '7.4.0' - - id 'jacoco' - id "org.sonarqube" version "7.1.0.6387" } group = 'app.esiroi' @@ -38,17 +35,6 @@ ext { set('snippetsDir', file("build/generated-snippets")) } -jacoco { - toolVersion = "0.8.11" -} - -sonar { - properties { - property "sonar.java.source", "21" - property "sonar.java.target", "21" - property "sonar.coverage.jacoco.xmlReportPaths", "build/reports/jacoco/test/jacocoTestReport.xml" - } -} tasks.register('generateJavaClient', GenerateTask) { generatorName = "java" @@ -98,6 +84,7 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'app.esiroi:auth-gen:1.0.0' implementation 'org.flywaydb:flyway-core' compileOnly 'org.projectlombok:lombok' @@ -117,15 +104,8 @@ dependencies { tasks.named('test') { outputs.dir snippetsDir useJUnitPlatform() - finalizedBy jacocoTestReport } -jacocoTestReport { - reports { - xml.required = true - html.required = true - } -} tasks.named('asciidoctor') { inputs.dir snippetsDir diff --git a/doc/api.yml b/doc/api.yml index 95eb075..5cb0a8e 100644 --- a/doc/api.yml +++ b/doc/api.yml @@ -7,7 +7,7 @@ servers: - url: 'http://localhost:8080' paths: - /v1/ping: + /ping: get: tags: - Health @@ -28,7 +28,7 @@ paths: application/json: schema: $ref: '#/components/schemas/Exception' - /v1/login: + /login: post: tags: - Security @@ -54,7 +54,33 @@ paths: schema: $ref: '#/components/schemas/Exception' - /v1/validateOTP: + /register: + post: + tags: + - Security + summary: Create user + security: [] + operationId: createUser + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthUser' + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Authenticated User + 403: + description: Bad credentials + content: + application/json: + schema: + $ref: '#/components/schemas/Exception' + + /validateOTP: post: tags: - Security diff --git a/env.template b/env.template index dd9b5c3..eb65cc3 100644 --- a/env.template +++ b/env.template @@ -6,6 +6,6 @@ SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/mydb SPRING_DATASOURCE_USERNAME: myuser SPRING_DATASOURCE_PASSWORD: mypassword SPRING_JPA_HIBERNATE_DDL_AUTO: none -SPRING_PROFILES_ACTIVE: prod +SPRING_PROFILES_ACTIVE: test CRYPTO_SALT: a1b2c3d4e5f67890abcdef1234567890 CRYPTO_SECRET_KEY: test \ No newline at end of file diff --git a/src/main/java/app/esiroi/auth/endpoint/controller/AuthenticationController.java b/src/main/java/app/esiroi/auth/endpoint/controller/AuthenticationController.java index 295b257..0a21a87 100644 --- a/src/main/java/app/esiroi/auth/endpoint/controller/AuthenticationController.java +++ b/src/main/java/app/esiroi/auth/endpoint/controller/AuthenticationController.java @@ -2,27 +2,87 @@ import app.esiroi.auth.endpoint.mapper.UserRestMapper; import app.esiroi.auth.endpoint.rest.model.AuthUser; -import app.esiroi.auth.endpoint.rest.model.User; +import app.esiroi.auth.endpoint.security.AuthProvider; import app.esiroi.auth.service.UserService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; -@RestController -@RequestMapping("/v1") +@Controller @AllArgsConstructor public class AuthenticationController { private final UserService service; private final UserRestMapper mapper; + @GetMapping("/") + public String index(Model model) { + model.addAttribute("user", new AuthUser()); + return "index"; + } + @PostMapping("/login") - public User authenticateUser(@RequestBody AuthUser toAuthenticate) { + public String login(@ModelAttribute AuthUser toAuthenticate, HttpServletResponse response) { var user = service.authenticateUser(toAuthenticate); - return mapper.toRest(user); + var cookie = putTokenInCookie(user.getAccessToken()); + response.addCookie(cookie); + + return "redirect:/validateOTP"; + } + + @PostMapping("/logout") + public String logout(HttpServletResponse response) { + var cookie = clearInCookie(); + response.addCookie(cookie); + return "redirect:/"; + } + + @PostMapping("/register") + public String register(@ModelAttribute AuthUser toRegister) { + var toSave = mapper.toDomain(toRegister); + service.saveUser(toSave); + return "redirect:/validateOTP"; + } + + @GetMapping("/register") + public String registerPage(Model model) { + model.addAttribute("user", new AuthUser()); + return "register"; + } + + @GetMapping("/validateOTP") + public String validateOTP() { + return "otp"; + } + + @GetMapping("/profile") + public String profile(Model model) { + var email = AuthProvider.getAuthenticatedUserEmail(); + model.addAttribute("email", email); + return "profile"; } @PostMapping("/validateOTP") - public User validateOTP(@RequestBody String otp) { - var persisted = service.validateOTP(otp); - return mapper.toRest(persisted); + public String validate(@RequestParam("otp") String otp) { + service.validateOTP(otp); + return "redirect:/profile"; + } + + private Cookie putTokenInCookie(String token) { + Cookie cookie = new Cookie("AUTH-TOKEN", token); + cookie.setHttpOnly(true); + cookie.setPath("/"); + + return cookie; + } + + private Cookie clearInCookie() { + Cookie cookie = new Cookie("AUTH-TOKEN", null); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge(0); + return cookie; } } diff --git a/src/main/java/app/esiroi/auth/endpoint/controller/HealthController.java b/src/main/java/app/esiroi/auth/endpoint/controller/HealthController.java index 2fa2d64..d2a89a1 100644 --- a/src/main/java/app/esiroi/auth/endpoint/controller/HealthController.java +++ b/src/main/java/app/esiroi/auth/endpoint/controller/HealthController.java @@ -1,11 +1,9 @@ package app.esiroi.auth.endpoint.controller; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/v1") public class HealthController { @GetMapping("/ping") diff --git a/src/main/java/app/esiroi/auth/endpoint/mapper/UserRestMapper.java b/src/main/java/app/esiroi/auth/endpoint/mapper/UserRestMapper.java index 85ed68b..bbf4e8e 100644 --- a/src/main/java/app/esiroi/auth/endpoint/mapper/UserRestMapper.java +++ b/src/main/java/app/esiroi/auth/endpoint/mapper/UserRestMapper.java @@ -1,10 +1,20 @@ package app.esiroi.auth.endpoint.mapper; +import static java.util.UUID.randomUUID; + +import app.esiroi.auth.endpoint.rest.model.AuthUser; import app.esiroi.auth.endpoint.rest.model.User; +import app.esiroi.auth.endpoint.security.Encryptor; +import lombok.AllArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; @Component +@AllArgsConstructor public class UserRestMapper { + private final PasswordEncoder passwordEncoder; + private final Encryptor encryptor; + public User toRest(app.esiroi.auth.model.User user) { return new User() .id(user.getId()) @@ -13,4 +23,15 @@ public User toRest(app.esiroi.auth.model.User user) { .otpValidationRequired(user.isOtpValidationRequired()) .accessToken(user.getAccessToken()); } + + public app.esiroi.auth.model.User toDomain(AuthUser user) { + var hashedPass = passwordEncoder.encode(user.getPassword()); + var encryptedSecret = encryptor.getInstance().encrypt("test".getBytes()); + return app.esiroi.auth.model.User.builder() + .id(randomUUID().toString()) + .email(user.getEmail()) + .passwordHash(hashedPass) + .otpSecret(encryptedSecret) + .build(); + } } diff --git a/src/main/java/app/esiroi/auth/endpoint/security/JWTAuthFilter.java b/src/main/java/app/esiroi/auth/endpoint/security/JWTAuthFilter.java index ba40595..fae7a38 100644 --- a/src/main/java/app/esiroi/auth/endpoint/security/JWTAuthFilter.java +++ b/src/main/java/app/esiroi/auth/endpoint/security/JWTAuthFilter.java @@ -2,10 +2,12 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import lombok.AllArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -22,10 +24,15 @@ public class JWTAuthFilter extends OncePerRequestFilter { protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String authHeader = request.getHeader("Authorization"); - - if (authHeader != null && authHeader.startsWith("Bearer ")) { - String token = authHeader.substring(7); + var cookies = request.getCookies(); + if (cookies != null) { + String token = + Arrays.stream(request.getCookies()) + .filter(cookie -> "AUTH-TOKEN".equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst() + .orElse(null); + ; if (jwtConf.validateToken(token)) { String email = jwtConf.extractEmail(token); diff --git a/src/main/java/app/esiroi/auth/endpoint/security/SecurityConf.java b/src/main/java/app/esiroi/auth/endpoint/security/SecurityConf.java index 7f1c75d..2ce39ed 100644 --- a/src/main/java/app/esiroi/auth/endpoint/security/SecurityConf.java +++ b/src/main/java/app/esiroi/auth/endpoint/security/SecurityConf.java @@ -4,8 +4,8 @@ import static org.springframework.web.cors.CorsConfiguration.ALL; import java.util.List; -import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -19,32 +19,63 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.HandlerExceptionResolver; @Configuration @Slf4j @EnableWebSecurity -@AllArgsConstructor public class SecurityConf { private final JWTAuthFilter authFilter; + private final HandlerExceptionResolver handlerExceptionResolver; + + public SecurityConf( + JWTAuthFilter authFilter, + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver) { + this.authFilter = authFilter; + this.handlerExceptionResolver = handlerExceptionResolver; + } + @Bean public SecurityFilterChain configure(HttpSecurity http) throws Exception { http.cors(corsConf -> corsConf.configurationSource(corsConfigurationSource())) + .exceptionHandling( + exceptions -> + exceptions.authenticationEntryPoint( + (request, response, authException) -> { + handlerExceptionResolver.resolveException( + request, response, null, authException); + })) .addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class) .authorizeHttpRequests( authorize -> authorize + .requestMatchers("/css/**", "/js/**", "/images/**") + .permitAll() .requestMatchers(OPTIONS, "/**") .permitAll() - .requestMatchers(GET, "/v1/ping") + .requestMatchers(GET, "/ping") + .permitAll() + .requestMatchers(GET, "/") .permitAll() - .requestMatchers(POST, "/v1/login") + .requestMatchers(POST, "/login") .permitAll() - .requestMatchers(POST, "/v1/validateOTP") + .requestMatchers(POST, "/logout") + .permitAll() + .requestMatchers(GET, "/register") + .permitAll() + .requestMatchers(POST, "/register") + .permitAll() + .requestMatchers(GET, "/validateOTP") + .authenticated() + .requestMatchers(POST, "/validateOTP") + .authenticated() + .requestMatchers(GET, "/profile") .authenticated() .anyRequest() .denyAll()) .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); diff --git a/src/main/java/app/esiroi/auth/service/UserService.java b/src/main/java/app/esiroi/auth/service/UserService.java index a975617..b0dea10 100644 --- a/src/main/java/app/esiroi/auth/service/UserService.java +++ b/src/main/java/app/esiroi/auth/service/UserService.java @@ -39,6 +39,10 @@ public User authenticateUser(AuthUser toAuthenticate) { throw new ForbiddenException("Bad credentials"); } + public User saveUser(User user) { + return repository.save(user); + } + public User validateOTP(String otp) { var email = AuthProvider.getAuthenticatedUserEmail(); var authUser = getUserByEmail(email); diff --git a/src/main/resources/static/css/index.css b/src/main/resources/static/css/index.css new file mode 100644 index 0000000..e1d56e3 --- /dev/null +++ b/src/main/resources/static/css/index.css @@ -0,0 +1,58 @@ +body { + font-family: Arial, sans-serif; + background: #f4f6f8; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; +} + +.card { + background: white; + padding: 4rem; + width: 320px; + border-radius: 8px; + box-shadow: 0 4px 10px rgba(0,0,0,0.1); +} + +h2 { + text-align: center; + margin-bottom: 1.5rem; +} + +input { + width: 93%; + padding: 10px; + margin: 8px 0 16px; + border: 1px solid #ccc; + border-radius: 4px; +} + +button { + width: 100%; + padding: 10px; + background: #4CAF50; + border: none; + color: white; + font-weight: bold; + border-radius: 4px; + cursor: pointer; +} + +button:hover { + background: #43a047; +} + +.message { + text-align: center; + font-size: 0.9em; +} + +.error { + color: #d32f2f; +} + +a { + color: #4CAF50; + text-decoration: none; +} \ No newline at end of file diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..4f5b17f --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,27 @@ + + + + + + Login + + + +
+

Login

+ +
+ + + + +
+ +

+ Don't have an account ? + Register +

+
+ + + diff --git a/src/main/resources/templates/otp.html b/src/main/resources/templates/otp.html new file mode 100644 index 0000000..146c66d --- /dev/null +++ b/src/main/resources/templates/otp.html @@ -0,0 +1,21 @@ + + + + + Validate OTP + + + +
+

Validate OTP

+ +
+ + + +
+ +
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/profile.html b/src/main/resources/templates/profile.html new file mode 100644 index 0000000..ac72561 --- /dev/null +++ b/src/main/resources/templates/profile.html @@ -0,0 +1,64 @@ + + + + + Profil + + + +
+ Avatar +

Welcome

+

+
+ +
+
+ + diff --git a/src/main/resources/templates/register.html b/src/main/resources/templates/register.html new file mode 100644 index 0000000..8cb7117 --- /dev/null +++ b/src/main/resources/templates/register.html @@ -0,0 +1,41 @@ + + + + + + Register + + + +
+

Register

+ +
+ + + + + +
+ +

+ Have an account ? + Login +

+
+ + + + +