Trong bài viết này, tôi sẽ hướng dẫn các phần chính sau:
- Tải và sử dụng Kaizen
- Phân loại user theo role. User với role admin sau khi login sẽ truy cập Admin dashboard page, user thường thì sẽ truy cập đến page khác
- Tạo 1 token đơn giản sau mỗi lần đăng nhập và lưu token này vào trong database, không sử dụng JWT Token
- Tạo 1 function kiểm token đã hết hạn hoặc chưa. Nếu token đã hết hạn -> call api bất kỳ -> báo lỗi 401
- Tạo các function xử lý Thêm - Xoá - Sửa thông tin user tại Admin dashboard
Nội dung nâng cao:
- Tạo 1 function tự động tại FE sau một thời gian nhất định -> được call để kiểm tra token hết hạn hoặc chưa và sẽ tự động xin cấp mới token và remove token cũ
Vui lòng tham khảo lại những bài viết sau để biết cách tạo 1 project với Spring Boot - Thymeleaf - Elasticsearch:
Nguồn templates html: https://templatemo.com/tag/bootstrap-5
Công cụ và thư viện được sử dụng trong bài viết:
- Spring boot 2.7.4
- Spring tool suite 4
- Spring data elasticsearch 4.4.2
- Maven 3
- Java 11
- Elasticsearch 7.17.6
- Elasticsearch head extension
- Kaizen Elastic
1. Tải và sử dụng Kaizen:
Trong những bài viết trước đó tôi sử dụng extension "Elasticsearch head", vì nó nhẹ và cài đặt nhanh chóng, giao diện Elasticsearch Head:
Hôm nay tôi giới thiệu thêm 1 công cụ tương tự như Elasticsearch Head như hỗ trợ nhiều tính năng hơn là Kaizen Elastic
Link: https://elastic-kaizen.com/download
Tải về phiên bản phù hợp, ở đây tôi tải về bản mới nhất
Sau khi tải xong, giải nén được thư mục như hình:
Bên trong thư mục, sẽ có 1 file "kaizen.bat", chạy file này lên sẽ có giao diện:
Trên giao diện, click vào icon hình đám mây hoặc click "Server" -> click "Connect" hoặc nhấn tổ hợp phím "Alt + O":
Tại giao diện "Connections", mặc định đã có 1 host được tạo sẵn, chỉ cần click chọn host được tạo sẵn và click icon đám mây(Connect):
=> Kết nối thành công
Với những tính năng khác các bạn có thể tự tìm hiểu vì trong bài viết này tôi không đi sâu vào công cụ này
2. Cấu trúc project:
### Nội dung file "pom.xml":
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.4</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.example</groupId> <artifactId>Token</artifactId> <version>0.0.1-SNAPSHOT</version> <name>Thymeleafs</name> <description>Demo project for Spring Boot</description> <properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
3. Tạo 1 package "com.example.token.application":
Nội dung class "TokenApplication":
4. Tạo 1 package "com.example.token.model":
Nội dung class "User":
Nội dung class "Token":
5. Tạo 1 package "com.example.token.repository":
Nội dung class "UserRepository":
Trong class này tôi có khai báo thêm 1 phương thức là "findByUserId", tôi muốn nhắc nhở các bạn không phải muốn đặt tên phương thức như nào cũng được
Tôi lấy ví dụ để các bạn dễ hiểu, trong class này tôi sẽ sửa lại tên phương thức "findByUserId" đến "findByPhone" và nó sẽ báo lỗi khi bạn start project:
Nguyên nhân là do khi đặt tên phương thức các bạn cần lưu ý điều này, ví dụ trong model "User" tôi không có thuộc tính tên "phone" nên khi tôi khai báo 1 phương thức với tên "findByPhone" -> lỗi
Bây giờ tôi sẽ quay lại class model "User" và thêm 1 thuộc tính là "phone":
Restart lại project và thấy không có lỗi:
Như vậy, kết luận để khai báo thêm 1 phương thức trong interface repository, cần đặt tên phương thức theo quy tắc:
Ví dụ: findBy + "tên thuộc tính có khai báo trong class model"
Nội dung class "TokenModel":
6. Tao 1 package "com.example.token.controller":
Nội dung "UserController":
Tôi sẽ tạo trước 1 mapping để load trang đăng nhập "index.html":
Restart project và truy cập url "https://localhost:8080/". Cần chú ý do trong bài viết trước tôi đã có hướng dẫn enable SSL và tạo certificate nên bài viết này tôi không nói lại, bạn nào không muốn sử dụng SSL thì vào "application.yml" và update lại giá trị "true" thành "false" cho SSL
=> Page đăng nhập load thành công
Tôi tạo 1 mapping trong controller để load page dashboard:
Với user thường không phải admin khi đăng nhập sẽ cho chuyển hướng đến page khác, ở đây là "user.html":
7. Tạo 1 package "com.example.token.service":
Nội dung class "UserService":
Nội dung interface "IUserService":
Tôi có sử dụng mã hoá Base64 cho mã hoá thông tin password khi gửi dữ liệu từ FE và khai báo 1 phương thức giúp giải mã(decode) về lại utf8. Nếu bạn không muốn sử dụng có thể tuỳ chỉnh lại code
Sau khi hoàn thành các thành phần cần thiết. Tiếp theo, tôi xử lý tự động tạo 1 token mỗi lần user login
8. Tạo token mỗi lần user login:
Tôi giả định tôi là admin và tài khoản admin của tôi đã có trong hệ thống. Nhưng tôi sẽ tạo 1 api để tạo user và dùng postman để call api này
Trong UserController tôi thêm mapping "/createUser":
Tôi mở Postman và call api "https://localhost:8080/createUser", method = "POST":
Phần body data:
Kết quả:
=> Tạo user thành công. Tôi mở Kaizen lên để kiểm tra:
Thông tin đã được tạo thành công và password được mã hoá
Tiếp theo, tôi sử dụng tài khoản admin vừa tạo để đăng nhập vào trang dashboard:
Tôi tạo 1 mapping "/login" trong controller:
Vui lòng tham khảo bài viết bên dưới tôi đã có hướng dẫn tạo function xử lý tại FE:
Đây là script file tôi xử lý cho login page "index.html":
Tôi tiến hành đăng nhập với tài khoản admin:
=> Login thành công
Tiếp theo, tôi sẽ tạo tự động token cho mỗi user sau khi xác thực thành công user có trong hệ thống. Trong bài viết này tôi không sử dụng JWT token hay oauth2 bởi vì tôi muốn các bản hiểu 1 cách đơn giản nhất có thể về token
Thông thường nếu không tạo token, khi bạn call 1 api bất kỳ nó sẽ không yêu cầu bạn xác thực thông tin gì cả nhưng nếu có token cho mỗi lần call api nó sẽ yêu cầu phải có token, nếu không cung cấp token hợp lệ -> failed
Trong một vài dự án dùng multiservice thì token sẽ được quản lý bởi ouath2 service và mỗi lần call api việc đầu tiên là sẽ kiểm tra token có tồn tại hay không và token đã hết hạn chưa, nếu token hết hạn -> văng lỗi
Trước tiên tôi tạo 1 phương thức sử dụng "UUID" để random ra token, tôi sẽ khai báo trong "IUserService" và "UserService":
Tại "IUserService" tôi khai báo:
Tại "UserService":
Tôi restart lại project và thử đăng nhập để kiểm tra token được sinh ra:
=> Token được tạo thành công
Tại UserController, phương thức "checkInfoUserLogin" với mapping "/login" tôi sửa code lại để lưu token vào database:
Restart lại project và đăng nhập lại lần nữa để kiểm tra xem token có được lưu vào database thành công không:
=> Token được lưu thành công, các bạn thấy hiện tại trong database có 2 token, do trước đó tôi đã có đăng nhập. Nếu muốn xoá record token nào thì chỉ cần click chọn record đó rồi click button "Remove"
Sau khi tạo token thành công, tôi tạo 1 function "getUser", trong function này tôi sẽ lấy giá trị token trên header để kiểm tra trong database có tồn tại không:
Tôi dùng postman để kiểm tra với 1 số kịch bản:
Kịch bản 1: Không set header
=> Trả về lỗi
Kịch bản 2: Set header nhưng nhập token không đúng
=> Trả về lỗi
Kịch bản 3: Set header với token đúng
=> Trả về data user thành công
Tiếp theo, trong model class "Token" tôi khai báo thêm 1 thuộc tính là "tokenExpiredAt", thuộc tính này dùng để lưu thời gian mà token được tạo ra, và mỗi lần call api khi check token sẽ cần kiểm tra xem token này còn hạn hay hết hạn:
Tôi khai báo 1 thuộc tính "token.expired.in" in application.yml với đơn vị tính là bằng phút. Ví dụ, tôi muốn sau 1 phút thì token mới hết hạn nghĩa là thời gian sống của token là 1 phút, tôi thiết lập giá trị như bên dưới:
token: expired: in: 1
Tôi tạo 1 phương thức để tính toán thời gian token sẽ hết hạn:
Trong IUserService:
Trong UserService:
Trong class "UserService" tôi cần lấy giá trị thuộc tính vừa tạo trong application.yml để sử dụng trong phương thức "getTokenExpiredAt":
Thời gian sống của token sẽ bằng thời gian token được tạo ra(ở đây tôi lấy theo thời gian hiện tại) + thời gian mà tôi chỉ định token này sẽ sống trong bao lâu(ở đây chính là giá trị thuộc tính khai báo trong application,yml trước đó được tính bằng phút và tôi quy đổi ra thành milisecond)
long currentTime = new Date().getTime() + TimeUnit.MINUTES.toMillis(tokenExpiredIn);
Tiếp theo, tôi tạo thêm phương thức để kiểm tra token mỗi khi user đăng nhập
Ví dụ, user đăng nhập lần 1 -> generate new token -> user không logout và close tab hiện tại -> user vào lại thì sẽ kiểm tra xem token của user này đã hết hạn chưa, nếu chưa thì không cần tạo mới token và login đến dashboard, nếu token đã hết hạn -> redirect về lại trang đăng nhập
Trong IUserService:
Trong UserService:
Tôi cần khai báo TokenRepository, tôi dùng annotation "@AutoWired":
Phương thức "checkToken":
Trong phương thức này, cần quan tâm vấn đề tính thời gian hết hạn cho token:
long tokenExpiredAt = obj.getTokenExpiredAt(); long currentTime = new Date().getTime(); if(tokenExpiredAt - currentTime > 0) { // not expired yet return true; }else { // token existed but expired -> delete token expired // remove old token tokenRepo.delete(obj); }
- Giá trị "tokenExpiredAt": Chính là giá trị thời gian khi token được sinh ra + thời gian sống của token
- Giá trị "currentTime": Lấy ra thời gian hiện tại
Và để tính thời gian token hết hạn hay chưa tôi lấy "tokenExpiredAt - cuurentTime". Để cho dễ hiểu, tôi lấy ví dụ như bên dưới:
Giả sử tôi có token được sinh ra vào lúc "10:00 AM" và tôi chỉ định token sống trong 5 phút -> có nghĩa là sau "10:05 AM" token sẽ hết hạn và sau 1 khoảng thời gian 6 phút thì thời gian hiện tại là "10:06 AM", theo công thức tính ở trên:
Lấy "10:05 AM" - "10:06 AM" -> giá trị sẽ nhỏ hơn 0 -> token hết hạn
Tiếp theo, sau khi tạo phương thức "checkToken", tôi tạo tiếp 2 phương thức "createCookie" và "getValueCookie" , lưu giá trị "token" và "tokenExpiredAt" tại cookie, lý do tôi sẽ nói ở phần sau:
Phương thức "createCookie":
Trong "IUserService":
Trong "UserService":
Phương thức "getValueCookie":
Trong "IUserService":
Trong "UserService":
Tôi cần khai báo "HttpServletRequest":
Sau khi chuẩn bị xong, tôi quay lại UserController và sửa lại phương thức "checkInfoUserLogin" với mapping name "/login":
Bây giờ, tôi restart lại project mà kiểm tra với 1 số kịch bản:
Kịch bản 1: User đăng nhập lần đầu -> tạo new token, trước tiên tôi kiểm tra database xem có token nào không:
=> Không có token nào
Tôi tiến hành đăng nhập với user "admin"
=> Token và "tokenExpiredAt" được thêm vào cookie thành công
Kiểm tra database:
=> "Token" và "tokenExpiredAt" được lưu thành công
Kịch bản 1: User close tab và mở lại -> sẽ kiểm tra token hết hạn hoặc chưa, ở đây tôi đang chỉ định token sống trong 1 phút
Tôi thử đăng nhập lại vào page dashboard -> sẽ check token, tôi xử lý thêm tại mapping "/dashboard":
Tôi lấy giá trị token được lưu tại cookie trước đó và gọi function kiểm tra token để kiểm tra:
=> Sau khi token hết hạn sẽ bị remove ra khỏi database
Tiếp theo, tại trang dashboard admin tôi xứ lý tiếp tính năng như Thêm - Xoá - Sửa user:
Đây là giao diện dashboard dành cho admin, tại page này tôi sửa lại thông tin 1 vài field cho phù hợp với User model của tôi
Sau khi thay đổi, tôi có 6 fields là "UserId"(required) - "Email" - "First Name"(required) - "Last Name" - "Address" - "Birth Day"(required)
Trước khi đi vào xử lý các chức năng Thêm - Xoá - Sửa user, tôi tạo thêm 2 package "com.example.token.filter" và "com.example.token.config" cho mục đích filter tất cả request url:
Trong package "com.example.token.filter":
Tôi tạo 1 class "UserFilter" với annotation "@Component":
Trong class "UserFilter" tôi khai báo 3 thành phần "HttpServletRequest" - "HttpServletResponse" - "IUserService" và tôi implements "Filter", tôi chỉ cần sử dụng phương thức "doFilter" để đọc tất cả request gửi đến:
**Trong phương thức "doFilter" tôi chỉ lọc 3 mapping tương ứng với Thêm - Xoá - Sửa và xem thông tin user": **
- "/updateUser" để xử lý cho cập nhật và tạo mới user
- "/getUser" để xử lý xem thông tin user
- "/deleteUser" để xử lý xoá user
Ở đây tôi dùng "startsWith" nghĩa là nếu request url gửi đến có dạng " /getUser/** " -> đều được đi vào
Sau khi tạo xong class UserFilter, tôi tạo tiếp class UserConfig trong package "com.example.token.config":
Xong phần chuẩn bị tại BE, tôi qua FE và tạo 1 script file "script.js" trong thư mục "static" để xử lý phần gửi data đến BE và thêm token trong request headers. Tại html page "dashboard.html" tôi thêm script file "script.js":
Cấu trúc project:
Trong script file, tôi cần tạo 3 phương thức chính:
- getCookie(): Trong phần trên tôi đã thêm token vào cookie nên tại đây khi gửi data đến BE tôi cần lấy ra giá trị token này để kiểm tra token hết hạn hoặc không tại BE
Tham khảo thêm về cookie tại đây: https://www.w3schools.com/js/js_cookies.asp
- getToken(): Phương thức chỉ để trả về giá trị token từ phương thức ở trên, tôi không muốn dùng chung với "getCookie()"
- updateUser(): Phương thức này để validate các fields bắt buộc, gửi data đến BE và sau khi lấy được token từ cookie -> thêm 1 header với định dạng {"Authorization": "Bearer " + token} hoặc {"Authorization":token} hoặc với bất kỳ tên nào miễn là có thể lấy ra giá trị token trong request headers
Cập nhật phương thức "updateUser()" tại button "Save changes" trong "dashboard.html":
Trong phương thức "updateUser" có 3 vấn đề quan trọng:
- Validate data, có 3 fields bắt buộc cần validate là "UserId" - "First Name" - "Birth Day":
Ở đây tôi đã tạo 3 html tag với nội dung "<span style="color:red" name = "message"></span>" tại 3 vị trí fields cần validate:
- Tạo 1 password theo 1 pattern cụ thể: "P@ssword" + "birthDate"(yyyymmdd). Ví dụ: user có birthDate là 01-01-1990 -> password sẽ là "P@ssword19900101":
- Gửi data đến BE, tại đây tôi lấy ra giá trị token lưu trong cookie từ phương thức "getToken()" và add headers đến request headers:
Vậy là phần chuẩn bị tại FE đã xong, tôi sẽ kiểm tra 1 kịch bản tại FE cho trường hợp validate data:
Tôi bỏ trống những fields bắt buộc và click button "Save changes", kiểm tra kết quả:
=> Validate data thành công
Tiếp theo, xử lý tại BE cho Thêm và Cập nhật user:
Thêm - Cập nhật User:
Tại UserController tạo phương thức "updateUser":
Tiến hành kiểm tra cho kịch bản tạo mới user:
=> Tạo mới user thành công
Tiếp theo, tôi kiểm tra cho kịch bản nếu token user admin hết hạn -> sẽ không thể call đến api "updateUser" và văng lỗi "401". Tôi chờ cho qua 1 phút bởi vì tôi chỉ định thời gian token sống là 1 phút:
=> Cho lần thứ 2 call api "updateUser" -> token expired -> văng lỗi
Lần thứ 3:
Kiểm tra database:
=> User mới được thêm thành công
Tôi thử đăng nhập với user vừa tạo(user thường) -> sẽ không thể load trang dashboard admin:
=> Với user thường sẽ chỉ load page "user.html"
Kiểm tra database với token được tạo mới:
=> Token được tạo mới và lưu thành công
Phương thức "getUserPage" mapping "/user":
Tiếp theo, tôi qua tính năng Xoá user:
Xoá thông tin User:
Thêm phương thức "deleteUser()" trong script file:
Khai báo phương thức này trong html:
Tạo 1 phương thức "deleteUser" với mapping "/deleteUser" trong UserController:
Kiểm tra với kịch bản delete user không tồn tại:
=> User này không có trong system nên sẽ báo lỗi
Kiểm tra với kịch bản delete user đã có:
=> User deleted success
Kiểm tra với kịch bản delete user nhưng token hết hạn:
=> Trả về lỗi 401
Tiếp theo, tôi xử lý thêm 1 vài tính năng nâng cao, tại FE tôi tạo 1 function trigger sau thời gian chỉ định call đến BE để xin cấp mới token
Ví dụ, tôi đăng nhập và token được tạo ra với thời gian sống là 2 phút, thì tôi xử lý tại FE sau thời gian 1 phút sẽ tự động call đến BE để xin cấp mới token, nghĩa là khi token còn 1 phút nữa là hết hạn thì functione sẽ được gọi để call đến BE
Tạo 1 function tự động tại FE sau một thời gian nhất định call đến BE để xin cấp mới token:
Trước tiên tôi tạo 1 mapping xử lý việc xin cấp mới token tại BE UserController, các bạn có thể tạo riêng 1 controller khác như "TokenController" cho xử lý chỉ về token để dễ quản lý:
Tiếp theo, tôi tạo 1 phương thức trong script file:
Các phần xử lý chính:
-
Lấy ra giá trị "tokenExpiredAt" tại cookie(milisecond)
-
Lấy giá trị thời gian hiện tại(milisecond)
-
Thời gian thực thi, tôi dùng công thức như bên dưới:
let executeAfter = Number(tokenExpiredAt) - Number(currentTimeInMillis) - 60 * 1000;
Tôi lấy ví dụ cho dễ hiểu công thức trên:
Ví dụ: Tôi đăng nhập và vào thời điểm "09:00 AM" token được tạo và tôi chỉ định token này sống trong 2 phút -> thời gian hết hạn của token(tokenExpiredAt) sẽ vào khoảng "09:02 AM lẻ 2 giây". Để tính ra thời gian thực thi, tôi lấy giá trị "tokenExpiredAt" là "09:02 AM" - thời gian hiện tại ví dụ là "09:00 AM lẻ 10 giây", thời gian thì chắc chắn có chênh lệch tầm 1-2s. Và theo công thứ ở trên sẽ là:
"09:02 AM lẻ 2 giây" - "09:00 AM lẻ 10 giây" - 60*1000
Ở đây 60 chính là 60 giây và tôi muốn quy đổi ra thành milisecond -> tôi lấy 60 * 1000
Ta có thể tính rợ theo phút như: 2phút - 0phút - 1phút = 1 phút => nghĩa là sau 1 phút(thực sự khoảng 58-59 giây) nữa phương thức "extendToken" sẽ được gọi để thực hiện việc request BE generate 1 token mới
Tôi tiến hành kiểm tra cho kịch bản xin cấp mới token khi token còn khoảng 1 phút nữa là hết hạn:
Sau khi đăng nhập thành công, kiểm tra thông tin token:
Thời gian thực thi tính bằng milisecond, tôi có in ra giá trị này:
Sau thời gian 1 phút, phương thức "extendToken" được thực thi:
Tôi kiểm tra lại database xem dữ liệu đã được cập nhật lại chưa:
=> Dữ liệu được cập nhật thành công
Tôi kiểm tra lại xem cookie đã được cập nhật giá trị mới chưa:
=> Cookie không cập nhật lại giá trị token mới mặc dù tôi đã create cookie. Kiểm tra lại phương thức "createCookie":
Phương thức "createCookie":
Trong phương thức "extendToken" tôi đã gọi lại phương create cookie nhưng tại browser nó không cập nhật lại theo giá trị mới:
Để xử lý vấn đề này, tôi thêm khai báo "path" khi tạo cookie:
Bây giờ, tôi restart lại project và kiểm tra lại xem cookie có cập nhật lại giá trị mới chưa:
Database lưu dữ liệu thành công và đường dẫn "path" được cập nhật mới
Sau 1 phút, phương thức "extendToken" thực thi:
Kiểm tra lại thông tin cookie:
=> Được cập nhật thành công
Như vậy xong phần xử lý để tự động call request BE cấp mới token, phương thức "extendToken" sẽ call liên tục cứ sau mỗi 1 phút:
Để tối ưu, tôi có cập nhật lại 1 số phương thức chính:
UserController:
1. Phương thức "updateUser": Dùng chung cho cả tạo mới và cập nhật user
2. Phương thức "deleteUser":
3. Phương thức "checkInfoUserLogin": Dùng để xử lý khi user đăng nhập:
4. Phương thức "getDashboardPage": Dùng để xử lý khi load dashboard page:
5. Phương thức "extendToken": Dùng để xử lý khi FE gửi request cấp mới token:
IUserService:
UserService:
UserFilter:
FE - Nội dung script file:
1. Phương thức "requestData": Dùng để xử lý khi user đăng nhập và đăng ký. Cho phần đăng ký tôi có hướng dẫn trong bài viết trước, bài viết này tôi không sử dụng phương thức đăng ký
2. Phương thức "updateUser": Dùng để xử lý cho cả 2 tạo mới và cập nhật user
3. Phương thức "deleteUser": Dùng để xử lý cho xoá thông tin user
4. Phương thức "extendToken": Dùng để xử lý tự động trigger call request BE cấp mới token