diff --git a/AuthSystem.sql b/AuthSystem.sql new file mode 100644 index 0000000..d1c750c --- /dev/null +++ b/AuthSystem.sql @@ -0,0 +1,167 @@ +-- =============================== +-- CREATE DATABASE +-- =============================== +IF DB_ID('AuthSystem') IS NULL +BEGIN + CREATE DATABASE AuthSystem; +END +GO + +USE AuthSystem; +GO + + +-- =============================== +-- 1. USERS TABLE +-- Lưu tài khoản người dùng và admin +-- =============================== +CREATE TABLE users ( + id INT IDENTITY(1,1) PRIMARY KEY, + + username NVARCHAR(50) NOT NULL UNIQUE, + email NVARCHAR(255) NOT NULL UNIQUE, + + password_hash NVARCHAR(500) NOT NULL, + hash_algorithm NVARCHAR(50) NOT NULL DEFAULT 'BCRYPT', + + role NVARCHAR(20) NOT NULL DEFAULT 'USER', + + failed_attempts INT NOT NULL DEFAULT 0, + locked_until DATETIME2 NULL, + + is_active BIT NOT NULL DEFAULT 1, + + last_login_at DATETIME2 NULL, + password_changed_at DATETIME2 NULL, + + created_at DATETIME2 NOT NULL DEFAULT GETDATE(), + updated_at DATETIME2 NULL, + + CONSTRAINT ck_users_role CHECK (role IN ('USER', 'ADMIN')) +); +GO + + +-- =============================== +-- 2. LOGIN LOGS TABLE +-- Lưu log đăng nhập thành công / thất bại +-- =============================== +CREATE TABLE login_logs ( + id INT IDENTITY(1,1) PRIMARY KEY, + + user_id INT NULL, + username NVARCHAR(50) NOT NULL, + + success BIT NOT NULL, + reason NVARCHAR(255) NULL, + + ip_address NVARCHAR(100) NULL, + user_agent NVARCHAR(500) NULL, + + created_at DATETIME2 NOT NULL DEFAULT GETDATE(), + + CONSTRAINT fk_login_logs_users + FOREIGN KEY (user_id) REFERENCES users(id) +); +GO + + +-- =============================== +-- 3. PASSWORD RESET OTP TABLE +-- Lưu OTP quên mật khẩu +-- OTP nên được hash bằng BCrypt trước khi lưu +-- =============================== +CREATE TABLE password_reset_otps ( + id INT IDENTITY(1,1) PRIMARY KEY, + + user_id INT NOT NULL, + + otp_hash NVARCHAR(500) NOT NULL, + + expires_at DATETIME2 NOT NULL, + is_used BIT NOT NULL DEFAULT 0, + + failed_attempts INT NOT NULL DEFAULT 0, + + created_at DATETIME2 NOT NULL DEFAULT GETDATE(), + used_at DATETIME2 NULL, + + CONSTRAINT fk_password_reset_otps_users + FOREIGN KEY (user_id) REFERENCES users(id) +); +GO + + +-- =============================== +-- 4. SESSIONS TABLE +-- Lưu session/JWT đang hoạt động +-- Dùng để vô hiệu hóa session cũ sau khi đổi mật khẩu +-- =============================== +CREATE TABLE sessions ( + id INT IDENTITY(1,1) PRIMARY KEY, + + user_id INT NOT NULL, + + jwt_id NVARCHAR(255) NOT NULL, + token_hash NVARCHAR(500) NULL, + + is_active BIT NOT NULL DEFAULT 1, + + created_at DATETIME2 NOT NULL DEFAULT GETDATE(), + expires_at DATETIME2 NOT NULL, + + revoked_at DATETIME2 NULL, + revoked_reason NVARCHAR(255) NULL, + + CONSTRAINT fk_sessions_users + FOREIGN KEY (user_id) REFERENCES users(id) +); +GO + + +-- =============================== +-- 5. PASSWORD CHANGE LOGS TABLE +-- Lưu log đổi mật khẩu / reset mật khẩu +-- =============================== +CREATE TABLE password_change_logs ( + id INT IDENTITY(1,1) PRIMARY KEY, + + user_id INT NOT NULL, + + change_type NVARCHAR(50) NOT NULL, + note NVARCHAR(255) NULL, + + ip_address NVARCHAR(100) NULL, + user_agent NVARCHAR(500) NULL, + + created_at DATETIME2 NOT NULL DEFAULT GETDATE(), + + CONSTRAINT fk_password_change_logs_users + FOREIGN KEY (user_id) REFERENCES users(id) +); +GO + + +-- =============================== +-- INDEXES +-- Giúp truy vấn log/session nhanh hơn +-- =============================== +CREATE INDEX idx_login_logs_user_id +ON login_logs(user_id); +GO + +CREATE INDEX idx_login_logs_created_at +ON login_logs(created_at); +GO + +CREATE INDEX idx_password_reset_otps_user_id +ON password_reset_otps(user_id); +GO + +CREATE INDEX idx_sessions_user_id +ON sessions(user_id); +GO + +CREATE INDEX idx_sessions_jwt_id +ON sessions(jwt_id); +GO \ No newline at end of file diff --git a/Backend/src/main/java/com/SP/SecurityPassword/Config/JwtAuthenticationFilter.java b/Backend/src/main/java/com/SP/SecurityPassword/Config/JwtAuthenticationFilter.java index a363941..860d82c 100644 --- a/Backend/src/main/java/com/SP/SecurityPassword/Config/JwtAuthenticationFilter.java +++ b/Backend/src/main/java/com/SP/SecurityPassword/Config/JwtAuthenticationFilter.java @@ -114,4 +114,9 @@ protected void doFilterInternal( filterChain.doFilter(request, response); } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return "OPTIONS".equalsIgnoreCase(request.getMethod()); + } } \ No newline at end of file diff --git a/Backend/src/main/java/com/SP/SecurityPassword/Config/SecurityConfig.java b/Backend/src/main/java/com/SP/SecurityPassword/Config/SecurityConfig.java index c11ca91..fb606c8 100644 --- a/Backend/src/main/java/com/SP/SecurityPassword/Config/SecurityConfig.java +++ b/Backend/src/main/java/com/SP/SecurityPassword/Config/SecurityConfig.java @@ -1,10 +1,11 @@ package com.SP.SecurityPassword.Config; -import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -17,77 +18,65 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.Arrays; import java.util.List; -import java.util.stream.Collectors; @Configuration @EnableWebSecurity -@RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; - @Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}") - private String allowedOrigins; + @Value("${app.cors.allowed-origin-patterns:http://localhost:5500,http://127.0.0.1:5500}") + private String allowedOriginPatterns; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) - http.csrf(csrf -> csrf.disable()); - - http.cors(cors -> cors.configurationSource(corsConfigurationSource())); + .cors(cors -> cors.configurationSource(corsConfigurationSource())) - http.sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ); + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) - http.authorizeHttpRequests(auth -> auth + .authorizeHttpRequests(auth -> auth - // Public APIs - .requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll() - .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll() - .requestMatchers(HttpMethod.POST, "/api/auth/forgot-password").permitAll() - .requestMatchers(HttpMethod.POST, "/api/auth/reset-password").permitAll() + // Cho phép request OPTIONS của CORS + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - // Swagger / OpenAPI - .requestMatchers( - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui.html" - ).permitAll() + // API public + .requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/forgot-password").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/reset-password").permitAll() - // Admin APIs - .requestMatchers("/api/admin/**").hasRole("ADMIN") + // API admin + .requestMatchers("/api/admin/**").hasRole("ADMIN") - // Other APIs - .anyRequest().authenticated() - ); + // Các API còn lại cần đăng nhập + .anyRequest().authenticated() + ) - http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - String originsProperty = allowedOrigins; - - if (originsProperty == null || originsProperty.isBlank()) { - originsProperty = "http://localhost:5173,http://localhost:3000"; - } - - List origins = Arrays.stream(originsProperty.split(",")) - .map(origin -> origin.trim()) + List origins = Arrays.stream(allowedOriginPatterns.split(",")) + .map(String::trim) .filter(origin -> !origin.isBlank()) - .collect(Collectors.toList()); + .toList(); configuration.setAllowedOriginPatterns(origins); + configuration.setAllowedMethods(List.of( "GET", "POST", @@ -96,12 +85,34 @@ public CorsConfigurationSource corsConfigurationSource() { "DELETE", "OPTIONS" )); - configuration.setAllowedHeaders(List.of("*")); + + configuration.setAllowedHeaders(List.of( + "Authorization", + "Content-Type", + "Accept", + "Origin", + "X-Requested-With", + "ngrok-skip-browser-warning" + )); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager( + AuthenticationConfiguration authenticationConfiguration + ) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } } \ No newline at end of file diff --git a/Backend/src/main/java/com/SP/SecurityPassword/Service/AuthService.java b/Backend/src/main/java/com/SP/SecurityPassword/Service/AuthService.java index 68d0b47..f74cdb6 100644 --- a/Backend/src/main/java/com/SP/SecurityPassword/Service/AuthService.java +++ b/Backend/src/main/java/com/SP/SecurityPassword/Service/AuthService.java @@ -14,6 +14,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; +import java.time.Duration; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; @@ -36,8 +37,8 @@ public class AuthService { @Value("${app.security.max-failed-attempts:5}") private int maxFailedAttempts; - @Value("${app.security.lock-duration-minutes:5}") - private int lockDurationMinutes; + @Value("${app.security.lock-duration-seconds:15}") + private long lockDurationSeconds; @Value("${app.security.otp-expiration-minutes:10}") private int otpExpirationMinutes; @@ -72,64 +73,91 @@ public UserResponse register(RegisterRequest request) { @Transactional public LoginResponse login(LoginRequest request, HttpServletRequest httpRequest) { - String loginIdentifier = request.getLoginIdentifier(); + String loginIdentifier = request.getLoginIdentifier(); - User user = findByUsernameOrEmail(loginIdentifier); + User user = findByUsernameOrEmail(loginIdentifier); - if (user == null) { - saveLoginLog(null, loginIdentifier, false, "USER_NOT_FOUND", httpRequest); - throw new IllegalArgumentException("Tài khoản hoặc mật khẩu không đúng"); - } + if (user == null) { + saveLoginLog(null, loginIdentifier, false, "USER_NOT_FOUND", httpRequest); + throw new IllegalArgumentException("Tài khoản hoặc mật khẩu không đúng"); + } - if (Boolean.FALSE.equals(user.getIsActive())) { - saveLoginLog(user, loginIdentifier, false, "ACCOUNT_INACTIVE", httpRequest); - throw new IllegalArgumentException("Tài khoản đã bị vô hiệu hóa"); - } + if (Boolean.FALSE.equals(user.getIsActive())) { + saveLoginLog(user, loginIdentifier, false, "ACCOUNT_INACTIVE", httpRequest); + throw new IllegalArgumentException("Tài khoản đã bị vô hiệu hóa"); + } - if (user.getLockedUntil() != null && user.getLockedUntil().isAfter(LocalDateTime.now())) { - saveLoginLog(user, loginIdentifier, false, "ACCOUNT_LOCKED", httpRequest); - throw new IllegalArgumentException("Tài khoản đang bị khóa tạm thời"); - } + if (user.getLockedUntil() != null) { + LocalDateTime now = LocalDateTime.now(); - boolean passwordMatches = passwordEncoder.matches( - request.getPassword(), - user.getPasswordHash() - ); + if (user.getLockedUntil().isAfter(now)) { + long remainingMillis = Duration.between(now, user.getLockedUntil()).toMillis(); + long remainingSeconds = Math.max(1, (long) Math.ceil(remainingMillis / 1000.0)); + + saveLoginLog(user, loginIdentifier, false, "ACCOUNT_LOCKED", httpRequest); - if (!passwordMatches) { - handleFailedLogin(user, loginIdentifier, httpRequest); - throw new IllegalArgumentException("Tài khoản hoặc mật khẩu không đúng"); + throw new IllegalArgumentException( + "Tài khoản đang bị khóa tạm thời. Vui lòng thử lại sau " + + remainingSeconds + " giây." + ); } user.setFailedAttempts(0); user.setLockedUntil(null); - user.setLastLoginAt(LocalDateTime.now()); userRepository.save(user); + } - String jwtId = jwtUtil.generateJwtId(); - String token = jwtUtil.generateToken(user, jwtId); + boolean passwordMatches = passwordEncoder.matches( + request.getPassword(), + user.getPasswordHash() + ); - UserSession session = UserSession.builder() - .user(user) - .jwtId(jwtId) - .tokenHash(null) - .isActive(true) - .expiresAt(LocalDateTime.now().plus(jwtUtil.getJwtExpiration(), ChronoUnit.MILLIS)) - .build(); + if (!passwordMatches) { + handleFailedLogin(user, loginIdentifier, httpRequest); - userSessionRepository.save(session); + if (user.getLockedUntil() != null && user.getLockedUntil().isAfter(LocalDateTime.now())) { + LocalDateTime now = LocalDateTime.now(); + long remainingMillis = Duration.between(now, user.getLockedUntil()).toMillis(); + long remainingSeconds = Math.max(1, (long) Math.ceil(remainingMillis / 1000.0)); + + throw new IllegalArgumentException( + "Tài khoản đang bị khóa tạm thời. Vui lòng thử lại sau " + + remainingSeconds + " giây." + ); + } - saveLoginLog(user, loginIdentifier, true, "LOGIN_SUCCESS", httpRequest); + throw new IllegalArgumentException("Tài khoản hoặc mật khẩu không đúng"); + } - return LoginResponse.builder() - .token(token) - .tokenType("Bearer") - .expiresIn(jwtUtil.getJwtExpiration()) - .userId(user.getId()) - .userName(user.getUserName()) - .email(user.getEmail()) - .role(user.getRole()) - .build(); + user.setFailedAttempts(0); + user.setLockedUntil(null); + user.setLastLoginAt(LocalDateTime.now()); + userRepository.save(user); + + String jwtId = jwtUtil.generateJwtId(); + String token = jwtUtil.generateToken(user, jwtId); + + UserSession session = UserSession.builder() + .user(user) + .jwtId(jwtId) + .tokenHash(null) + .isActive(true) + .expiresAt(LocalDateTime.now().plus(jwtUtil.getJwtExpiration(), ChronoUnit.MILLIS)) + .build(); + + userSessionRepository.save(session); + + saveLoginLog(user, loginIdentifier, true, "LOGIN_SUCCESS", httpRequest); + + return LoginResponse.builder() + .token(token) + .tokenType("Bearer") + .expiresIn(jwtUtil.getJwtExpiration()) + .userId(user.getId()) + .userName(user.getUserName()) + .email(user.getEmail()) + .role(user.getRole()) + .build(); } @Transactional @@ -250,7 +278,7 @@ private void handleFailedLogin(User user, String loginIdentifier, HttpServletReq user.setFailedAttempts(failedAttempts); if (failedAttempts >= maxFailedAttempts) { - user.setLockedUntil(LocalDateTime.now().plusMinutes(lockDurationMinutes)); + user.setLockedUntil(LocalDateTime.now().plusSeconds(lockDurationSeconds)); } userRepository.save(user); diff --git a/Backend/src/main/resources/application-example.properties b/Backend/src/main/resources/application-example.properties index 21ad95f..283c528 100644 --- a/Backend/src/main/resources/application-example.properties +++ b/Backend/src/main/resources/application-example.properties @@ -17,8 +17,9 @@ spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true server.port=8080 app.security.max-failed-attempts=5 -app.security.lock-duration-minutes=5 +app.security.lock-duration-seconds=15 app.security.otp-expiration-minutes=10 app.security.max-otp-failed-attempts=5 app.cors.allowed-origins=http://localhost:5173,http://localhost:3000 +app.cors.allowed-origin-patterns=http://localhost:5500,http://127.0.0.1:5500,http://localhost:3000,http://127.0.0.1:3000,https://*.ngrok-free.app spring.jackson.time-zone=Asia/Ho_Chi_Minh \ No newline at end of file diff --git a/README.md b/README.md index 6aac3c8..c31cb6b 100644 --- a/README.md +++ b/README.md @@ -1 +1,423 @@ -# SecurityPassword \ No newline at end of file +# SecurityPassword + +

+ Modern Password Authentication System – Ứng dụng SHA và TripleDES để bảo vệ mật khẩu người dùng trong cơ sở dữ liệu. +

+ +

+ + + + + + + + +

+ +## Giới thiệu đề tài + +**SecurityPassword** là project demo cho đề tài: + +**Modern Password Authentication System – Ứng dụng SHA và TripleDES để bảo vệ mật khẩu người dùng trong cơ sở dữ liệu** + +Mục tiêu của project là xây dựng một hệ thống xác thực mật khẩu hiện đại, trong đó mật khẩu người dùng không được lưu dưới dạng plaintext, không dùng SHA-256 đơn thuần và không dùng TripleDES để mã hóa mật khẩu. Thay vào đó, hệ thống sử dụng **BCrypt** để hash mật khẩu an toàn hơn. + +Project gồm: + +- Backend: Spring Boot REST API +- Frontend: HTML, CSS, JavaScript, Bootstrap +- Database: SQL Server +- Authentication: JWT +- Password Hashing: BCrypt +- OTP Reset Password: Email OTP + +## Video demo + +Video demo project: + +- Demo link: [SecurityPassword Demo](https://drive.google.com/file/d/11_E-1zDfIBBxZuQ26qqgpyoPLQX1TwZ_/view?usp=sharing) + +## Cấu trúc thư mục + +```text +SecurityPassword/ +├── Backend/ +│ ├── src/ +│ └── pom.xml +│ +├── Frontend/ +│ ├── login.html +│ ├── register.html +│ ├── forgot-password.html +│ ├── reset-password.html +│ ├── admin.html +│ ├── css/ +│ └── js/ +│ +├── doc/ +│ └── api/ +│ └── SecurityPassword_API_Documentation.md +│ +└── README.md +``` + +## Chức năng chính + +### User + +- Đăng ký tài khoản +- Đăng nhập bằng username hoặc email +- Mật khẩu được hash bằng BCrypt trước khi lưu vào database +- Đổi mật khẩu +- Quên mật khẩu bằng OTP gửi qua email +- Reset mật khẩu bằng OTP +- Tự động khóa tài khoản tạm thời nếu nhập sai mật khẩu nhiều lần +- Tự động mở khóa sau thời gian cấu hình + +### Admin + +- Đăng nhập bằng tài khoản có role `ADMIN` +- Xem danh sách user +- Xem lịch sử đăng nhập +- Xem danh sách session/token +- Xem log đổi/reset mật khẩu + +## Hướng dẫn chạy project + +### 1. Yêu cầu trước khi chạy + +Cần cài đặt các công cụ sau: + +- Java JDK 21+ +- SQL Server +- SQL Server Management Studio +- IntelliJ IDEA hoặc VS Code +- Git +- VS Code Live Server nếu chạy frontend HTML/CSS/JavaScript +- Ngrok nếu muốn public backend cho frontend gọi API + +Kiểm tra Java: + +```bash +java -version +``` + +Kiểm tra Maven: + +```bash +mvn -version +``` + +--- + +### 2. Clone source code + +```bash +git clone https://github.com/tienml/SecurityPassword.git +cd SecurityPassword +``` + +Lấy code mới nhất từ nhánh `main`: + +```bash +git pull origin main +``` + +--- + +### 3. Chuẩn bị database + +Mở SQL Server Management Studio và tạo database: + +```sql +CREATE DATABASE AuthSystem; +GO + +USE AuthSystem; +GO +``` + +Sau đó chạy file SQL tạo bảng nếu project có cung cấp. + +Các bảng chính của hệ thống: + +```text +users +login_logs +password_reset_otps +sessions +password_change_logs +``` + +--- + +### 4. Cấu hình backend + +Mở file: + +```text +Backend/src/main/resources/application.properties +``` + +Cấu hình theo máy local: + +```properties +spring.application.name=SecurityPassword + +server.port=8080 + +spring.datasource.url=jdbc:sqlserver://localhost:1433;databaseName=AuthSystem;encrypt=true;trustServerCertificate=true +spring.datasource.username=YOUR_DB_USERNAME +spring.datasource.password=YOUR_DB_PASSWORD +spring.datasource.driver-class-name=com.microsoft.sqlserver.jdbc.SQLServerDriver + +spring.jpa.hibernate.ddl-auto=none +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +app.jwt.secret=YOUR_JWT_SECRET_KEY +app.jwt.expiration=86400000 + +app.security.max-failed-attempts=5 +app.security.lock-duration-seconds=15 +app.security.otp-expiration-minutes=10 +app.security.max-otp-failed-attempts=5 + +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=YOUR_EMAIL +spring.mail.password=YOUR_GMAIL_APP_PASSWORD +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +app.cors.allowed-origin-patterns=http://localhost:5500,http://127.0.0.1:5500,https://*.ngrok-free.app,https://*.ngrok-free.dev +``` + +> Lưu ý: Không nên push `application.properties` chứa database password, Gmail app password hoặc JWT secret thật lên GitHub. + +--- + +### 5. Chạy backend + +Di chuyển vào thư mục backend: + +```bash +cd Backend +``` + +Chạy project bằng Maven: + +```bash +mvn spring-boot:run +``` + +Nếu project có Maven Wrapper, có thể chạy: + +```bash +./mvnw spring-boot:run +``` + +Trên Windows PowerShell: + +```powershell +.\mvnw spring-boot:run +``` + +Backend mặc định chạy tại: + +```text +http://localhost:8080 +``` + +--- + +### 6. Chạy frontend + +Mở project bằng VS Code. + +Cài extension: + +```text +Live Server +``` + +Mở file: + +```text +Fontend/login.html +``` + +Click chuột phải và chọn: + +```text +Open with Live Server +``` + +Frontend thường chạy tại: + +```text +http://127.0.0.1:5500 +``` + +hoặc: + +```text +http://localhost:5500 +``` + +> Lưu ý: Frontend đang cấu hình chạy bằng ngrok https://grandpa-discharge-isolated.ngrok-free.dev, nếu muốn chạy bằng local thì cấu hình lại thành http://127.0.0.1:5500 hoặc http://localhost:5500. + +--- + +### 7. Chạy backend qua ngrok + +Nếu frontend cần gọi backend qua ngrok, chạy lệnh: + +```bash +ngrok http 8080 +``` + +Ngrok sẽ tạo URL dạng: + +```text +https://example.ngrok-free.dev +``` + +Copy URL này vào file JavaScript frontend: + +```js +const DEFAULT_BASE_URL = "https://example.ngrok-free.dev"; +``` + +Nếu dùng ngrok free, frontend có thể thêm header: + +```js +"ngrok-skip-browser-warning": "true" +``` + +Ví dụ: + +```js +fetch(`${BASE_URL}/api/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "ngrok-skip-browser-warning": "true" + }, + body: JSON.stringify({ + loginIdentifier: "admin", + password: "Admin@123456" + }) +}); +``` + +--- + +## Tài khoản demo + +### User thường + +Có thể đăng ký trực tiếp trên giao diện hoặc gọi API: + +```http +POST /api/auth/register +``` + +Request body: + +```json +{ + "userName": "tien", + "email": "tien@example.com", + "password": "Tien@123456" +} +``` + +Sau khi đăng ký, tài khoản mặc định có role: + +```text +USER +``` + +--- + +### Admin + +Hệ thống không cho người dùng tự đăng ký tài khoản admin từ frontend để tránh lỗ hổng phân quyền. + +Cách tạo tài khoản admin trong demo: + +#### Bước 1: Đăng ký tài khoản admin bằng API register + +```http +POST /api/auth/register +``` + +Request body: + +```json +{ + "userName": "admin", + "email": "admin@example.com", + "password": "Admin@123456" +} +``` + +Tài khoản vừa tạo ban đầu vẫn có role: + +```text +USER +``` + +#### Bước 2: Cập nhật role admin trong SQL Server + +Chạy câu lệnh sau trong SQL Server Management Studio: + +```sql +UPDATE users +SET role = 'ADMIN' +WHERE username = 'admin'; +``` + +#### Bước 3: Đăng nhập lại bằng tài khoản admin + +```http +POST /api/auth/login +``` + +Request body: + +```json +{ + "loginIdentifier": "admin", + "password": "Admin@123456" +} +``` + +Nếu đăng nhập thành công, response sẽ có: + +```json +{ + "role": "ADMIN" +} +``` + +--- + +## API chính + +| Method | Endpoint | Auth | Role | Chức năng | +|---|---|---|---|---| +| POST | `/api/auth/register` | No | Any | Đăng ký tài khoản | +| POST | `/api/auth/login` | No | Any | Đăng nhập | +| POST | `/api/auth/forgot-password` | No | Any | Gửi OTP reset mật khẩu | +| POST | `/api/auth/reset-password` | No | Any | Reset mật khẩu bằng OTP | +| GET | `/api/auth/me` | Yes | USER/ADMIN | Lấy thông tin tài khoản hiện tại | +| POST | `/api/auth/change-password` | Yes | USER/ADMIN | Đổi mật khẩu | +| GET | `/api/admin/users` | Yes | ADMIN | Xem danh sách user | +| GET | `/api/admin/login-logs` | Yes | ADMIN | Xem log đăng nhập | +| GET | `/api/admin/sessions` | Yes | ADMIN | Xem session/token | +| GET | `/api/admin/password-change-logs` | Yes | ADMIN | Xem log đổi/reset mật khẩu | + +--- diff --git a/doc/api/SecurityPassword_API_Documentation.md b/doc/api/SecurityPassword_API_Documentation.md new file mode 100644 index 0000000..bbf77d8 --- /dev/null +++ b/doc/api/SecurityPassword_API_Documentation.md @@ -0,0 +1,903 @@ +# SecurityPassword API Documentation + +**Project:** Modern Password Authentication System +**Backend:** Spring Boot REST API +**Database:** SQL Server +**Authentication:** JWT Bearer Token +**Password hashing:** BCrypt +**Main response format:** `ApiResponse` + +--- + +## 1. Base URL + +### Local backend + +```text +http://localhost:8080 +``` + +### Khi dùng ngrok + +```text +https://your-backend-ngrok-url.ngrok-free.app +``` + +Frontend chỉ cần thay `BASE_URL` theo môi trường. + +```js +const BASE_URL = "http://localhost:8080"; +// hoặc +const BASE_URL = "https://your-backend-ngrok-url.ngrok-free.app"; +``` + +--- + +## 2. Response format chung + +Tất cả API nên trả về theo format: + +```json +{ + "success": true, + "message": "Thông báo kết quả", + "data": {} +} +``` + +Khi lỗi: + +```json +{ + "success": false, + "message": "Nội dung lỗi", + "data": null +} +``` + +--- + +## 3. Authentication header + +Các API cần đăng nhập phải gửi JWT trong header: + +```http +Authorization: Bearer +``` + +Ví dụ: + +```js +headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" +} +``` + +--- + +# 4. Public Auth APIs + +Các API này **không cần token**. + +--- + +## 4.1. Register + +Đăng ký tài khoản người dùng thường. + +```http +POST /api/auth/register +``` + +### Request body + +```json +{ + "userName": "tien", + "email": "tien@example.com", + "password": "Tien@123456" +} +``` + +### Validation + +| Field | Rule | +|---|---| +| `userName` | Không rỗng, 4–50 ký tự | +| `email` | Không rỗng, đúng định dạng email | +| `password` | Không rỗng, tối thiểu 8 ký tự | + +### Lưu ý cho frontend + +- Không gửi field `role`. +- Backend luôn tạo tài khoản mới với `role = USER`. +- Mật khẩu được hash bằng BCrypt trước khi lưu database. + +### Success response + +```json +{ + "success": true, + "message": "Đăng ký tài khoản thành công", + "data": { + "id": 1, + "userName": "tien", + "email": "tien@example.com", + "role": "USER", + "isActive": true, + "failedAttempts": 0, + "lockedUntil": null, + "lastLoginAt": null, + "passwordChangedAt": null, + "createdAt": "2026-07-01T10:00:00" + } +} +``` + +### Possible errors + +```json +{ + "success": false, + "message": "Username đã tồn tại", + "data": null +} +``` + +```json +{ + "success": false, + "message": "Email đã tồn tại", + "data": null +} +``` + +--- + +## 4.2. Login + +Đăng nhập bằng username hoặc email. + +```http +POST /api/auth/login +``` + +### Request body + +```json +{ + "loginIdentifier": "tien", + "password": "Tien@123456" +} +``` + +Hoặc: + +```json +{ + "loginIdentifier": "tien@example.com", + "password": "Tien@123456" +} +``` + +### Success response + +```json +{ + "success": true, + "message": "Đăng nhập thành công", + "data": { + "token": "eyJhbGciOiJIUzI1NiJ9...", + "tokenType": "Bearer", + "expiresIn": 86400000, + "userId": 1, + "userName": "tien", + "email": "tien@example.com", + "role": "USER" + } +} +``` + +### Frontend cần làm sau khi login thành công + +Lưu token và thông tin user. + +Ví dụ: + +```js +localStorage.setItem("token", response.data.token); +localStorage.setItem("role", response.data.role); +localStorage.setItem("userName", response.data.userName); +``` + +Điều hướng: + +```text +role = USER → chuyển vào trang user/home +role = ADMIN → chuyển vào trang admin/dashboard +``` + +### Possible errors + +```json +{ + "success": false, + "message": "Tài khoản hoặc mật khẩu không đúng", + "data": null +} +``` + +```json +{ + "success": false, + "message": "Tài khoản đang bị khóa tạm thời", + "data": null +} +``` + +```json +{ + "success": false, + "message": "Tài khoản đã bị vô hiệu hóa", + "data": null +} +``` + +--- + +## 4.3. Forgot Password + +Gửi OTP về email để reset mật khẩu. + +```http +POST /api/auth/forgot-password +``` + +### Request body + +```json +{ + "email": "tien@example.com" +} +``` + +### Success response + +```json +{ + "success": true, + "message": "OTP đã được gửi về email", + "data": null +} +``` + +### Frontend flow + +1. Người dùng nhập email. +2. Gọi API `/api/auth/forgot-password`. +3. Nếu thành công, chuyển sang trang nhập OTP và mật khẩu mới. +4. Truyền email sang trang reset password. +5. Ở trang reset password, hiển thị email dạng chỉ đọc, không cho sửa. + +Ví dụ UI: + +```text +Email: tien@example.com [readonly] +OTP: [______] +Mật khẩu mới: [______] +Xác nhận mật khẩu mới: [______] +``` + +### Possible errors + +```json +{ + "success": false, + "message": "Email không tồn tại trong hệ thống", + "data": null +} +``` + +--- + +## 4.4. Reset Password + +Xác thực OTP và đặt lại mật khẩu mới. + +```http +POST /api/auth/reset-password +``` + +### Request body + +```json +{ + "email": "tien@example.com", + "otp": "123456", + "newPassword": "NewPassword@123" +} +``` + +### Lưu ý + +- `email` vẫn phải gửi lên backend dù frontend không cho người dùng sửa email. +- Backend dùng email để xác định OTP thuộc tài khoản nào. +- OTP được hash trong database, backend dùng BCrypt để verify. +- Sau khi reset password thành công, toàn bộ session/token cũ của user bị vô hiệu hóa. + +### Success response + +```json +{ + "success": true, + "message": "Đặt lại mật khẩu thành công", + "data": null +} +``` + +### Frontend cần làm sau khi reset thành công + +```text +1. Xóa token cũ nếu có. +2. Chuyển về trang login. +3. Hiển thị thông báo: Đặt lại mật khẩu thành công, vui lòng đăng nhập lại. +``` + +### Possible errors + +```json +{ + "success": false, + "message": "OTP không tồn tại hoặc đã hết hạn", + "data": null +} +``` + +```json +{ + "success": false, + "message": "OTP không đúng", + "data": null +} +``` + +```json +{ + "success": false, + "message": "OTP đã nhập sai quá nhiều lần", + "data": null +} +``` + +--- + +# 5. Authenticated User APIs + +Các API này **cần token**. + +--- + +## 5.1. Get Current User + +Lấy thông tin tài khoản đang đăng nhập. + +```http +GET /api/auth/me +``` + +### Headers + +```http +Authorization: Bearer +``` + +### Success response + +```json +{ + "success": true, + "message": "Lấy thông tin tài khoản thành công", + "data": { + "id": 1, + "userName": "tien", + "email": "tien@example.com", + "role": "USER", + "isActive": true, + "failedAttempts": 0, + "lockedUntil": null, + "lastLoginAt": "2026-07-01T10:20:00", + "passwordChangedAt": null, + "createdAt": "2026-07-01T10:00:00" + } +} +``` + +--- + +## 5.2. Change Password + +Đổi mật khẩu khi người dùng/admin đang đăng nhập. + +```http +POST /api/auth/change-password +``` + +### Headers + +```http +Authorization: Bearer +``` + +### Request body + +```json +{ + "currentPassword": "OldPassword@123", + "newPassword": "NewPassword@123" +} +``` + +### Success response + +```json +{ + "success": true, + "message": "Đổi mật khẩu thành công. Vui lòng đăng nhập lại.", + "data": null +} +``` + +### Frontend cần làm sau khi đổi mật khẩu thành công + +Do backend vô hiệu hóa toàn bộ session/token cũ sau khi đổi mật khẩu, frontend cần: + +```text +1. Xóa token khỏi localStorage/sessionStorage. +2. Xóa role/userName nếu đã lưu. +3. Chuyển về trang login. +4. Hiển thị thông báo: Đổi mật khẩu thành công, vui lòng đăng nhập lại. +``` + +Ví dụ: + +```js +localStorage.removeItem("token"); +localStorage.removeItem("role"); +localStorage.removeItem("userName"); +navigate("/login"); +``` + +### Possible errors + +```json +{ + "success": false, + "message": "Mật khẩu hiện tại không đúng", + "data": null +} +``` + +```json +{ + "success": false, + "message": "Mật khẩu mới không được trùng mật khẩu hiện tại", + "data": null +} +``` + +--- + +# 6. Admin APIs + +Các API này cần token của tài khoản có: + +```text +role = ADMIN +``` + +Frontend cần gửi: + +```http +Authorization: Bearer +``` + +Nếu user thường gọi API admin, backend sẽ trả lỗi 403 Forbidden. + +--- + +## 6.1. Get All Users + +Lấy danh sách tài khoản. + +```http +GET /api/admin/users +``` + +### Success response + +```json +{ + "success": true, + "message": "Lấy danh sách user thành công", + "data": [ + { + "id": 1, + "userName": "admin", + "email": "admin@example.com", + "role": "ADMIN", + "isActive": true, + "failedAttempts": 0, + "lockedUntil": null, + "lastLoginAt": "2026-07-01T10:20:00", + "passwordChangedAt": null, + "createdAt": "2026-07-01T10:00:00" + }, + { + "id": 2, + "userName": "tien", + "email": "tien@example.com", + "role": "USER", + "isActive": true, + "failedAttempts": 0, + "lockedUntil": null, + "lastLoginAt": null, + "passwordChangedAt": null, + "createdAt": "2026-07-01T10:05:00" + } + ] +} +``` + +--- + +## 6.2. Get Login Logs + +Admin xem log đăng nhập thành công/thất bại. + +```http +GET /api/admin/login-logs +``` + +### Success response + +```json +{ + "success": true, + "message": "Lấy login logs thành công", + "data": [ + { + "id": 1, + "userId": 1, + "userName": "admin", + "success": true, + "reason": "LOGIN_SUCCESS", + "ipAddress": "127.0.0.1", + "userAgent": "Mozilla/5.0...", + "createdAt": "2026-07-01T10:20:00" + }, + { + "id": 2, + "userId": null, + "userName": "hacker123", + "success": false, + "reason": "USER_NOT_FOUND", + "ipAddress": "127.0.0.1", + "userAgent": "Mozilla/5.0...", + "createdAt": "2026-07-01T10:21:00" + } + ] +} +``` + +### Các reason có thể gặp + +```text +LOGIN_SUCCESS +USER_NOT_FOUND +WRONG_PASSWORD +ACCOUNT_LOCKED +ACCOUNT_INACTIVE +``` + +--- + +## 6.3. Get Sessions + +Admin xem danh sách session/JWT. + +```http +GET /api/admin/sessions +``` + +### Success response + +```json +{ + "success": true, + "message": "Lấy danh sách session thành công", + "data": [ + { + "id": 1, + "userId": 1, + "userName": "admin", + "jwtId": "8e6b1d21-9f5e-4d5a-b4a1-123456789abc", + "isActive": true, + "createdAt": "2026-07-01T10:20:00", + "expiresAt": "2026-07-02T10:20:00", + "revokedAt": null, + "revokedReason": null + }, + { + "id": 2, + "userId": 2, + "userName": "tien", + "jwtId": "7c6f1e11-1f2a-4a1a-b1c2-abcdef123456", + "isActive": false, + "createdAt": "2026-07-01T09:00:00", + "expiresAt": "2026-07-02T09:00:00", + "revokedAt": "2026-07-01T10:00:00", + "revokedReason": "PASSWORD_CHANGED" + } + ] +} +``` + +### Revoked reason có thể gặp + +```text +PASSWORD_CHANGED +PASSWORD_RESET +``` + +--- + +## 6.4. Get Password Change Logs + +Admin xem log đổi/reset mật khẩu. + +```http +GET /api/admin/password-change-logs +``` + +### Success response + +```json +{ + "success": true, + "message": "Lấy password change logs thành công", + "data": [ + { + "id": 1, + "userId": 2, + "userName": "tien", + "changeType": "CHANGE_PASSWORD", + "note": "User changed password", + "ipAddress": "127.0.0.1", + "userAgent": "Mozilla/5.0...", + "createdAt": "2026-07-01T10:30:00" + }, + { + "id": 2, + "userId": 2, + "userName": "tien", + "changeType": "RESET_PASSWORD", + "note": "Password reset by OTP", + "ipAddress": "127.0.0.1", + "userAgent": "Mozilla/5.0...", + "createdAt": "2026-07-01T11:00:00" + } + ] +} +``` + +--- + +# 7. Frontend flows + +--- + +## 7.1. Register flow + +```text +User nhập username, email, password +→ Frontend gọi POST /api/auth/register +→ Nếu thành công: chuyển sang login +→ Nếu lỗi: hiển thị message từ response +``` + +--- + +## 7.2. Login flow + +```text +User nhập username/email + password +→ Frontend gọi POST /api/auth/login +→ Nếu thành công: + - Lưu token + - Lưu role + - Nếu role = ADMIN → vào admin dashboard + - Nếu role = USER → vào user home +→ Nếu lỗi: + - Hiển thị message từ response +``` + +--- + +## 7.3. Protected API flow + +```text +Frontend lấy token đã lưu +→ Gửi header Authorization: Bearer +→ Backend kiểm tra: + - JWT hợp lệ + - JWT chưa hết hạn + - Session trong bảng sessions còn active + - User còn active +→ Nếu hợp lệ: cho truy cập +→ Nếu không hợp lệ: trả 401/403 +``` + +--- + +## 7.4. Change password flow + +```text +User/Admin đang đăng nhập +→ Nhập currentPassword + newPassword +→ Frontend gọi POST /api/auth/change-password kèm token +→ Backend: + - Verify currentPassword bằng BCrypt + - Hash newPassword bằng BCrypt + - Update password_hash + - Revoke toàn bộ session cũ + - Ghi password_change_logs +→ Frontend: + - Xóa token + - Chuyển về login +``` + +--- + +## 7.5. Forgot/reset password flow + +```text +Bước 1: Forgot password +User nhập email +→ Frontend gọi POST /api/auth/forgot-password +→ Backend tạo OTP 6 số, hash OTP, lưu DB, gửi email +→ Frontend chuyển sang trang reset password + +Bước 2: Reset password +Frontend tự truyền email sang trang reset +→ Email hiển thị readonly +→ User nhập OTP + mật khẩu mới +→ Frontend gọi POST /api/auth/reset-password +→ Backend verify OTP bằng BCrypt +→ Backend đổi password_hash +→ Backend revoke toàn bộ session cũ +→ Frontend chuyển về login +``` + +--- + +## 7.6. Admin flow + +```text +Admin login bằng POST /api/auth/login +→ Backend trả token với role = ADMIN +→ Frontend lưu token và role +→ Chuyển vào admin dashboard +→ Admin gọi: + GET /api/admin/users + GET /api/admin/login-logs + GET /api/admin/sessions + GET /api/admin/password-change-logs +``` + +--- + +# 8. Cách tạo tài khoản admin + +Hệ thống không cho người dùng tự đăng ký admin từ frontend. + +Cách tạo admin trong demo: + +### Bước 1: Đăng ký tài khoản admin bằng API register + +```json +{ + "userName": "admin", + "email": "admin@example.com", + "password": "Admin@123456" +} +``` + +Tài khoản này ban đầu có role: + +```text +USER +``` + +### Bước 2: Set role admin trong SQL Server + +```sql +UPDATE users +SET role = 'ADMIN' +WHERE username = 'admin'; +``` + +### Bước 3: Đăng nhập lại tài khoản admin + +```json +{ + "loginIdentifier": "admin", + "password": "Admin@123456" +} +``` + +Nếu login thành công, response sẽ có: + +```json +"role": "ADMIN" +``` + +--- + +# 9. Frontend notes + +## 9.1. Không hiển thị password hash + +Frontend không cần hiển thị `password_hash`. +Phần chứng minh mật khẩu đã được bảo vệ sẽ chụp trong SQL Server. + +## 9.2. Role-based routing + +Frontend nên có logic: + +```text +Nếu chưa có token → vào login +Nếu role = USER → vào trang user +Nếu role = ADMIN → vào trang admin +``` + +## 9.3. Token invalid sau đổi/reset mật khẩu + +Sau khi đổi mật khẩu hoặc reset mật khẩu, token cũ không dùng được nữa vì backend đã revoke session. + +Frontend nên xử lý: + +```text +Nếu API trả 401/403: +- Xóa token local +- Chuyển về login +``` + +## 9.4. Ngrok + +Nếu backend chạy qua ngrok, frontend gọi API bằng URL ngrok backend. + +Ví dụ: + +```js +const BASE_URL = "https://abc-xyz.ngrok-free.app"; +``` + +Backend cần cấu hình CORS cho domain frontend. + +--- + +# 10. Summary endpoints + +| Method | Endpoint | Auth | Role | Chức năng | +|---|---|---|---|---| +| POST | `/api/auth/register` | No | Any | Đăng ký user | +| POST | `/api/auth/login` | No | Any | Đăng nhập | +| POST | `/api/auth/forgot-password` | No | Any | Gửi OTP | +| POST | `/api/auth/reset-password` | No | Any | Reset mật khẩu bằng OTP | +| GET | `/api/auth/me` | Yes | USER/ADMIN | Lấy thông tin tài khoản hiện tại | +| POST | `/api/auth/change-password` | Yes | USER/ADMIN | Đổi mật khẩu | +| GET | `/api/admin/users` | Yes | ADMIN | Xem danh sách user | +| GET | `/api/admin/login-logs` | Yes | ADMIN | Xem log đăng nhập | +| GET | `/api/admin/sessions` | Yes | ADMIN | Xem session | +| GET | `/api/admin/password-change-logs` | Yes | ADMIN | Xem log đổi/reset mật khẩu |