Переглянути джерело

add reset password feature

Daniel Bohry 3 тижнів тому
батько
коміт
1f254a333a

+ 6 - 0
src/main/java/com/danielbohry/authservice/api/AuthController.java

@@ -51,4 +51,10 @@ public class AuthController {
         return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
     }
 
+    @PostMapping("forgot-password")
+    public ResponseEntity<Void> forgotPassword(@RequestParam String username) {
+        service.forgotPassword(username);
+        return ResponseEntity.ok().build();
+    }
+
 }

+ 28 - 12
src/main/java/com/danielbohry/authservice/api/UserController.java

@@ -2,21 +2,19 @@ package com.danielbohry.authservice.api;
 
 import com.danielbohry.authservice.api.dto.AuthenticationResponse;
 import com.danielbohry.authservice.api.dto.PasswordChangeRequest;
+import com.danielbohry.authservice.api.dto.PasswordResetRequest;
 import com.danielbohry.authservice.api.dto.UserResponse;
 import com.danielbohry.authservice.domain.ApplicationUser;
 import com.danielbohry.authservice.service.auth.AuthService;
 import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.security.core.context.SecurityContext;
 import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.web.bind.annotation.CrossOrigin;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
+
+import static org.springframework.http.HttpStatus.FORBIDDEN;
+import static org.springframework.http.HttpStatus.UNAUTHORIZED;
 
 @Slf4j
 @RestController
@@ -25,17 +23,20 @@ import org.springframework.web.bind.annotation.RestController;
 @RequestMapping("api/users")
 public class UserController {
 
-    private final AuthService authService;
+    private final AuthService service;
 
     @GetMapping("current")
     public ResponseEntity<UserResponse> get() {
         SecurityContext context = SecurityContextHolder.getContext();
         Object principal = context.getAuthentication().getPrincipal();
         if (principal instanceof ApplicationUser user) {
-            return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRoles().stream().map(Enum::toString).toList()));
+            return ResponseEntity.ok(new UserResponse(user.getId(), user.getUsername(), user.getRoles()
+                    .stream()
+                    .map(Enum::toString)
+                    .toList()));
         }
 
-        return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+        return ResponseEntity.status(FORBIDDEN).build();
     }
 
     @PostMapping("change-password")
@@ -45,10 +46,25 @@ public class UserController {
 
         if (principal instanceof ApplicationUser user) {
             log.info("Changing password for user [{}]", user.getUsername());
-            var response = authService.changePassword(user.getId(), request.getCurrentPassword(), request.getNewPassword());
+            var response = service.changePassword(user.getId(), request.getCurrentPassword(), request.getNewPassword());
             return ResponseEntity.ok(response);
         }
 
-        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+        return ResponseEntity.status(UNAUTHORIZED).build();
     }
+
+    @PostMapping("reset-password")
+    public ResponseEntity<AuthenticationResponse> resetPassword(@RequestBody PasswordResetRequest request) {
+        SecurityContext context = SecurityContextHolder.getContext();
+        Object principal = context.getAuthentication().getPrincipal();
+
+        if (principal instanceof ApplicationUser user) {
+            log.info("Resetting password for user [{}]", user.getUsername());
+            var response = service.resetPassword(user.getId(), request.getNewPassword());
+            return ResponseEntity.ok(response);
+        }
+
+        return ResponseEntity.status(UNAUTHORIZED).build();
+    }
+
 }

+ 14 - 0
src/main/java/com/danielbohry/authservice/api/dto/PasswordResetRequest.java

@@ -0,0 +1,14 @@
+package com.danielbohry.authservice.api.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class PasswordResetRequest {
+
+    private String newPassword;
+
+}

+ 42 - 0
src/main/java/com/danielbohry/authservice/client/MailClient.java

@@ -0,0 +1,42 @@
+package com.danielbohry.authservice.client;
+
+import com.danielbohry.authservice.exceptions.MailException;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Map;
+
+import static org.springframework.http.HttpMethod.POST;
+
+@Component
+public class MailClient {
+
+    private final RestTemplate rest;
+    private final String url;
+
+    public MailClient(RestTemplate rest, @Value("${mail.api}") String url) {
+        this.rest = rest;
+        this.url = url;
+    }
+
+    public void sendMail(String to, String subject, String content, String token) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.add("Authorization", token);
+
+        HttpEntity<Map<String, String>> entity = new HttpEntity<>(
+                Map.of("to", to, "subject", subject, "content", content),
+                headers
+        );
+
+        try {
+            rest.exchange(url + "/api/mail", POST, entity, Void.class);
+        } catch (Exception e) {
+            throw new MailException("Failed to send email", e);
+        }
+    }
+}

+ 15 - 0
src/main/java/com/danielbohry/authservice/config/RestConfig.java

@@ -0,0 +1,15 @@
+package com.danielbohry.authservice.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+public class RestConfig {
+
+    @Bean
+    RestTemplate rest() {
+        return new RestTemplate();
+    }
+
+}

+ 26 - 11
src/main/java/com/danielbohry/authservice/config/SecurityConfig.java

@@ -12,6 +12,7 @@ import org.springframework.security.config.annotation.authentication.configurati
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
 import org.springframework.security.crypto.password.PasswordEncoder;
 import org.springframework.security.web.SecurityFilterChain;
@@ -28,18 +29,32 @@ public class SecurityConfig {
     private final UserService userService;
 
     @Bean
-    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+    public SecurityFilterChain securityFilterChain(HttpSecurity http) {
         http
-            .csrf(AbstractHttpConfigurer::disable)
-            .authorizeHttpRequests(requests -> requests
-                .requestMatchers("/actuator/health", "/actuator/info", "/actuator/prometheus", "/api/register", "/api/authenticate").permitAll()
-                .requestMatchers("/", "/index.html", "/css/**", "/js/**", "/images/**").permitAll()
-                .requestMatchers("/api/users", "/api/authorize").authenticated()
-                .anyRequest().authenticated()
-            )
-            .sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS))
-            .authenticationProvider(authenticationProvider())
-            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+                .csrf(AbstractHttpConfigurer::disable)
+                .authorizeHttpRequests(requests -> requests
+                        .requestMatchers(
+                                "/actuator/health",
+                                "/actuator/info",
+                                "/actuator/prometheus",
+                                "/api/register",
+                                "/api/authenticate",
+                                "/api/forgot-password"
+                        ).permitAll()
+                        .requestMatchers(
+                                "/",
+                                "/index.html",
+                                "/css/**",
+                                "/js/**",
+                                "/images/**"
+                        ).permitAll()
+                        .requestMatchers("/api/users", "/api/authorize").authenticated()
+                        .anyRequest().authenticated()
+                )
+                .sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS))
+                .authenticationProvider(authenticationProvider())
+                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
+
         return http.build();
     }
 

+ 1 - 1
src/main/java/com/danielbohry/authservice/domain/Role.java

@@ -2,6 +2,6 @@ package com.danielbohry.authservice.domain;
 
 public enum Role {
 
-    ADMIN, USER, SERVICE, VPN, TESTER
+    SYSTEM, ADMIN, USER, SERVICE, VPN, TESTER
 
 }

+ 17 - 0
src/main/java/com/danielbohry/authservice/exceptions/MailException.java

@@ -0,0 +1,17 @@
+package com.danielbohry.authservice.exceptions;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
+public class MailException extends RuntimeException {
+
+    public MailException(String message) {
+        super(message);
+    }
+
+    public MailException(String message, Throwable e) {
+        super(message, e);
+    }
+
+}

+ 34 - 9
src/main/java/com/danielbohry/authservice/service/auth/AuthService.java

@@ -2,9 +2,12 @@ package com.danielbohry.authservice.service.auth;
 
 import com.danielbohry.authservice.api.dto.AuthenticationRequest;
 import com.danielbohry.authservice.api.dto.AuthenticationResponse;
+import com.danielbohry.authservice.client.MailClient;
 import com.danielbohry.authservice.domain.ApplicationUser;
+import com.danielbohry.authservice.exceptions.NotFoundException;
 import com.danielbohry.authservice.service.user.UserService;
-import lombok.AllArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.authentication.AuthenticationManager;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.authority.SimpleGrantedAuthority;
@@ -18,13 +21,17 @@ import org.springframework.stereotype.Service;
 import static com.danielbohry.authservice.service.auth.UserConverter.convert;
 
 @Service
-@AllArgsConstructor
+@RequiredArgsConstructor
 public class AuthService implements UserDetailsService {
 
     private final UserService service;
     private final JwtService jwtService;
     private final AuthenticationManager authenticationManager;
     private final PasswordEncoder passwordEncoder;
+    private final MailClient mailClient;
+
+    @Value("${host.name:localhost}")
+    private String host;
 
     @Override
     public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
@@ -43,7 +50,7 @@ public class AuthService implements UserDetailsService {
 
     public AuthenticationResponse authenticate(AuthenticationRequest request) {
         authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
-            request.getUsername(), request.getPassword())
+                request.getUsername(), request.getPassword())
         );
         ApplicationUser user = service.findByUsername(request.getUsername());
         Authentication authentication = jwtService.generateToken(user);
@@ -56,6 +63,24 @@ public class AuthService implements UserDetailsService {
         return buildResponse(user.getId(), authentication);
     }
 
+    public AuthenticationResponse resetPassword(String userId, String newPassword) {
+        ApplicationUser user = service.resetPassword(userId, newPassword, passwordEncoder);
+        Authentication authentication = jwtService.generateToken(user);
+        return buildResponse(user.getId(), authentication);
+    }
+
+    public void forgotPassword(String username) {
+        try {
+            ApplicationUser user = service.findByUsername(username);
+            Authentication systemAuth = jwtService.generateSystemToken();
+            Authentication userAuth = jwtService.generateToken(user, 10);
+
+            if (user.getEmail() != null)
+                mailClient.sendMail(user.getEmail(), "Password change requested", host + "/?reset-password&token=" + userAuth.token(), "Bearer " + systemAuth.token());
+        } catch (Exception ignored) {
+        }
+    }
+
     private UserDetails buildUserDetails(AuthenticationRequest request) {
         return User.builder()
                 .username(request.getUsername())
@@ -65,12 +90,12 @@ public class AuthService implements UserDetailsService {
 
     private static AuthenticationResponse buildResponse(String id, Authentication authentication) {
         return AuthenticationResponse.builder()
-            .id(id)
-            .username(authentication.username())
-            .token(authentication.token())
-            .expirationDate(authentication.expirationDate())
-            .roles(authentication.authorities())
-            .build();
+                .id(id)
+                .username(authentication.username())
+                .token(authentication.token())
+                .expirationDate(authentication.expirationDate())
+                .roles(authentication.authorities())
+                .build();
     }
 
 }

+ 58 - 19
src/main/java/com/danielbohry/authservice/service/auth/JwtService.java

@@ -4,17 +4,18 @@ import io.jsonwebtoken.Claims;
 import io.jsonwebtoken.ExpiredJwtException;
 import io.jsonwebtoken.Jwts;
 import io.jsonwebtoken.security.Keys;
-
-import javax.crypto.SecretKey;
-import java.nio.charset.StandardCharsets;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.User;
 import org.springframework.security.core.userdetails.UserDetails;
 import org.springframework.stereotype.Service;
 
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.util.*;
 import java.util.function.Function;
 
@@ -28,14 +29,15 @@ public class JwtService {
     @Value("${jwt.secret}")
     private String secret;
 
-    private static final Map<String, Long> ROLE_EXPIRATION_HOURS = Map.of(
-            "ADMIN", 1L,
-            "SERVICE", 12L,
-            "VPN", 24L,
-            "USER", 48L
+    private static final Map<String, Long> ROLE_EXPIRATION_MINUTES = Map.of(
+            "SYSTEM", 1L,
+            "ADMIN", 60L,
+            "SERVICE", 720L,
+            "VPN", 1440L,
+            "USER", 2880L
     );
 
-    private static final List<String> ROLE_PRIORITY = List.of("ADMIN", "SERVICE", "VPN");
+    private static final List<String> ROLE_PRIORITY = List.of("SYSTEM", "ADMIN", "SERVICE", "VPN");
 
     private SecretKey getSigningKey() {
         try {
@@ -60,16 +62,36 @@ public class JwtService {
         return claimsResolver.apply(claims);
     }
 
+    public Authentication generateToken(UserDetails userDetails, long minutes) {
+        Map<String, Object> claims = new HashMap<>();
+        claims.put("authorities", userDetails.getAuthorities().stream()
+                .map(GrantedAuthority::getAuthority)
+                .collect(toSet())
+        );
+        return generateToken(claims, userDetails, minutes);
+    }
+
     public Authentication generateToken(UserDetails userDetails) {
         Map<String, Object> claims = new HashMap<>();
-        claims.put(
-                "authorities", userDetails.getAuthorities().stream()
-                        .map(GrantedAuthority::getAuthority)
-                        .collect(toSet())
+        claims.put("authorities", userDetails.getAuthorities().stream()
+                .map(GrantedAuthority::getAuthority)
+                .collect(toSet())
         );
         return generateToken(claims, userDetails);
     }
 
+    public Authentication generateSystemToken() {
+        Map<String, Object> claims = new HashMap<>();
+        claims.put("authorities", Set.of("SYSTEM"));
+
+        UserDetails systemUser = User.builder()
+                .username("system")
+                .authorities(new SimpleGrantedAuthority("SYSTEM"))
+                .build();
+
+        return generateToken(claims, systemUser);
+    }
+
     public Boolean isTokenValid(String token, UserDetails userDetails) {
         final String username = extractUsername(token);
         return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
@@ -80,7 +102,24 @@ public class JwtService {
     }
 
     private Authentication generateToken(Map<String, Object> claims, UserDetails userDetails) {
-        Date expirationDate = new Date(currentTimeMillis() + 1000 * 60 * 60 * hoursByRole(claims));
+        Date expirationDate = new Date(currentTimeMillis() + 1000 * 60 * minutesByRole(claims));
+        String token = Jwts.builder()
+                .claims(claims)
+                .subject(userDetails.getUsername())
+                .issuedAt(new Date(currentTimeMillis()))
+                .expiration(expirationDate)
+                .signWith(getSigningKey())
+                .compact();
+
+        return new Authentication(token,
+                expirationDate.toInstant(),
+                userDetails.getUsername(),
+                userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList()
+        );
+    }
+
+    private Authentication generateToken(Map<String, Object> claims, UserDetails userDetails, long minutes) {
+        Date expirationDate = new Date(currentTimeMillis() + 1000 * 60 * minutes);
         String token = Jwts.builder()
                 .claims(claims)
                 .subject(userDetails.getUsername())
@@ -109,15 +148,15 @@ public class JwtService {
         }
     }
 
-    private long hoursByRole(Map<String, Object> claims) {
+    private long minutesByRole(Map<String, Object> claims) {
         @SuppressWarnings("unchecked")
         Set<String> authorities = (Set<String>) claims.get("authorities");
 
         return ROLE_PRIORITY.stream()
                 .filter(authorities::contains)
                 .findFirst()
-                .map(ROLE_EXPIRATION_HOURS::get)
-                .orElse(48L);
+                .map(ROLE_EXPIRATION_MINUTES::get)
+                .orElse(2880L);
     }
 
 }

+ 8 - 0
src/main/java/com/danielbohry/authservice/service/user/UserService.java

@@ -58,6 +58,14 @@ public class UserService {
         return repository.save(user);
     }
 
+    public ApplicationUser resetPassword(String userId, String newPassword, PasswordEncoder passwordEncoder) {
+        ApplicationUser user = repository.findById(userId)
+                .orElseThrow(() -> new NotFoundException("User not found"));
+
+        user.setPassword(passwordEncoder.encode(newPassword));
+        return repository.save(user);
+    }
+
     private void validateUsername(ApplicationUser applicationUser) {
         boolean exists = repository.existsByUsername(applicationUser.getUsername());
 

+ 111 - 0
src/main/resources/static/index.html

@@ -299,6 +299,28 @@
             </div>
         </div>
 
+        <div id="resetPasswordSection" style="display: none;">
+            <div class="header">
+                <h1>Reset Password</h1>
+                <p>Enter your new password below</p>
+            </div>
+
+            <div class="form-container">
+                <form class="form active" id="resetPasswordForm">
+                    <div class="form-group">
+                        <label for="resetNewPassword">New Password</label>
+                        <input type="password" id="resetNewPassword" name="newPassword" required>
+                    </div>
+                    <div class="form-group">
+                        <label for="resetConfirmPassword">Confirm New Password</label>
+                        <input type="password" id="resetConfirmPassword" name="confirmPassword" required>
+                    </div>
+                    <button type="submit" class="submit-btn" id="resetPasswordBtn">Reset Password</button>
+                    <div id="resetPasswordMessage" class="message" style="display: none;"></div>
+                </form>
+            </div>
+        </div>
+
         <div id="userSection" class="user-info">
             <div class="user-details">
                 <p><strong>Username:</strong> <span id="currentUsername"></span></p>
@@ -340,8 +362,28 @@
     <script>
         const API_BASE_URL = '/api';
         let currentUser = null;
+        let resetToken = null;
+
+        function parseURLParams() {
+            const urlParams = new URLSearchParams(window.location.search);
+            const hasResetFlag = urlParams.has('reset-password');
+            const token = urlParams.get('token');
+
+            return {
+                isResetMode: hasResetFlag && token,
+                resetToken: token
+            };
+        }
 
         document.addEventListener('DOMContentLoaded', function() {
+            const urlInfo = parseURLParams();
+
+            if (urlInfo.isResetMode) {
+                resetToken = urlInfo.resetToken;
+                showResetPasswordSection();
+                return;
+            }
+
             const token = localStorage.getItem('authToken');
             const userData = localStorage.getItem('userData');
 
@@ -393,6 +435,8 @@
                     button.innerHTML = 'Create Account';
                 } else if (buttonId.includes('changePassword')) {
                     button.innerHTML = 'Change Password';
+                } else if (buttonId.includes('resetPassword')) {
+                    button.innerHTML = 'Reset Password';
                 }
                 button.disabled = false;
             }
@@ -476,6 +520,58 @@
             }
         });
 
+        document.getElementById('resetPasswordForm').addEventListener('submit', async function(e) {
+            e.preventDefault();
+
+            const newPassword = document.getElementById('resetNewPassword').value;
+            const confirmPassword = document.getElementById('resetConfirmPassword').value;
+
+            // Validate password confirmation
+            if (newPassword !== confirmPassword) {
+                showMessage('resetPasswordMessage', 'New passwords do not match.', 'error');
+                return;
+            }
+
+            // Validate password length
+            if (newPassword.length < 4) {
+                showMessage('resetPasswordMessage', 'New password must be at least 6 characters long.', 'error');
+                return;
+            }
+
+            setButtonLoading('resetPasswordBtn', true);
+            clearResetPasswordMessages();
+
+            try {
+                const response = await fetch(`${API_BASE_URL}/users/reset-password`, {
+                    method: 'POST',
+                    headers: {
+                        'Content-Type': 'application/json',
+                        'Authorization': 'Bearer ' + resetToken
+                    },
+                    body: JSON.stringify({
+                        newPassword: newPassword
+                    })
+                });
+
+                if (response.ok) {
+                    showMessage('resetPasswordMessage', 'Password reset successfully!', 'success');
+
+                    document.getElementById('resetPasswordForm').reset();
+                    setTimeout(() => {
+                        window.location.href = window.location.pathname;
+                    }, 1000);
+                } else {
+                    const errorText = await response.text();
+                    showMessage('resetPasswordMessage', errorText || 'Failed to reset password. Please try again.', 'error');
+                }
+            } catch (error) {
+                console.error('Reset password error:', error);
+                showMessage('resetPasswordMessage', 'Network error. Please check if the auth service is running.', 'error');
+            } finally {
+                setButtonLoading('resetPasswordBtn', false);
+            }
+        });
+
         document.getElementById('changePasswordForm').addEventListener('submit', async function(e) {
             e.preventDefault();
 
@@ -534,6 +630,12 @@
             messageElement.textContent = '';
         }
 
+        function clearResetPasswordMessages() {
+            const messageElement = document.getElementById('resetPasswordMessage');
+            messageElement.style.display = 'none';
+            messageElement.textContent = '';
+        }
+
         function updateUserDisplay() {
             document.getElementById('currentUsername').textContent = currentUser.username;
             document.getElementById('currentUserId').textContent = maskUserId(currentUser.id);
@@ -549,6 +651,15 @@
             updateUserDisplay();
         }
 
+        function showResetPasswordSection() {
+            document.getElementById('authSection').style.display = 'none';
+            document.getElementById('userSection').classList.remove('active');
+            document.getElementById('resetPasswordSection').style.display = 'block';
+
+            // Focus on the first input field
+            document.getElementById('resetNewPassword').focus();
+        }
+
         function maskUserId(id) {
             const parts = id.split("-");
             return parts[0] + "-****-" + parts[4];