Trong phần 2 của series https://viblo.asia/p/phan-2-spring-boot-30-va-spring-security-60-qPoL7z8lJvk chúng ta đã hoàn thành bước phân quyền cho ứng dụng. Ở bài viết này, thay vì hard code user detail, chúng ta sẽ lưu và lấy dữ liệu user detail ra từ database
1. Khởi tạo database
Bước đầu tiên tất nhiên là tạo database, cú pháp như sau:
CREATE DATABASE spring_security;
CREATE USER 'security_su'@'localhost' IDENTIFIED BY '123456';
GRANT ALL PRIVILEGES ON spring_security.* TO 'security_su'@'localhost';
2. Kết nối Spring boot và database
Dưới đây là đoạn config kết nối database:
#spring.security.user.name=hach
#spring.security.user.password=hacheery
spring.datasource.url=jdbc:mysql://localhost:3306/spring_security
spring.datasource.username=security_su
spring.datasource.password=123456
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
# Logs the JDBC parameters passed to a query
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.properties.hibernate.format_sql=true
Ngoài ra để có thể kết nối đến database, chúng ta cần import maven mysql-connector-j vào pom.xml
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>
3. Khởi tạo entity
Để Spring boot có thể làm việc với database thì chúng ta cần phải có những entity tương ứng với table trong database. Đầu tiên chúng ta sẽ tạo package entity để lưu trữ entity ở trên.
Lưu ý: một số bạn sẽ thắc mắc có package model rồi thì còn thêm package entity làm gì? Để có thể hiểu được thì mời mọi người đọc bài viết này để nắm rõ hơn: https://viblo.asia/p/entity-domain-model-va-dto-sao-nhieu-qua-vay-YWOZroMPlQ0
Trong package model, chúng ta sẽ tạo class UserInfo:
Có một lưu ý nhỏ ở đây là để có thể sử dụng được các annotation bên dưới, chúng ta cần phải thêm dependency Spring Data JPA vào file pom.xml như sau:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency>
package com.example.springsecurity.entity; import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor; /** * Created by HachNV on 29/05/2023 */
@Entity // dùng để khai báo với Spring Boot rằng đây là 1 entity biểu diễn table trong db
@Data // annotation này sẽ tự động khai báo getter và setter cho class
@AllArgsConstructor // dùng để khai báo constructor với tất cả các properties
@NoArgsConstructor // dùng để khai báo constructor rỗng không có param
public class UserInfo { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; private String name; private String email; private String password; private String roles;
}
Ngoài ra, nếu mọi người muốn tìm hiểu kỹ hơn thì có thể search từng annotation ra để hiểu rõ.
4. Khởi tạo repository
Repository là thành phần quan trọng có trách nhiệm giao tiếp với các DB, xử lý query và trả về các kiểu dữ liệu mà service yêu cầu. Ở đây chúng ta sẽ khởi tạo package repository và khai báo interface UserInfoRepository như sau:
package com.example.springsecurity.repository; import com.example.springsecurity.entity.UserInfo;
import org.springframework.data.jpa.repository.JpaRepository; /** * Created by HachNV on Mar 03, 2023 */
public interface UserInfoRepository extends JpaRepository<UserInfo, Integer> {
}
Như mọi người thấy thì mình có extends JpaRepository. Đây là 1 interface có chứa những chức năng cơ bản như thêm sửa xóa, paging, sorting,... giúp chúng ta giảm thiểu số lượng code dư thừa. Ví dụ với việc lưu data thì thay vì phải viết các dòng lệnh thì chúng ta có sẵn hàm userInfoRepository.save(userInfo). Và ngoài ra còn khá nhiều hàm hỗ trợ khác nữa mọi người có thể xem source để hiểu thêm
5. Khởi tạo service
Tầng Service chính là tầng xử lý logic và đưa ra yêu cầu cho Repository để query dữ liệu Ở trong SecurityConfig chúng ta có 1 đoạn hard code username và password như dưới đây: Thay vì hard code chúng ta sẽ comment đoạn code trên và tạo service tên là UserInfoDetailService nằm trong package service:
package com.example.springsecurity.config; import com.example.springsecurity.entity.UserInfo;
import com.example.springsecurity.repository.UserInfoRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component; import java.util.Optional; /** * Created by HachNV on Mar 06, 2023 */
@Component
@RequiredArgsConstructor
public class UserInforDetailService implements UserDetailsService { private UserInfoRepository userInfoRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Optional<UserInfo> userInfo = userInfoRepository.findByName(username); return null; }
}
Ở đây chúng ta implements UserDetailsService để tận dùng hàm có sẵn loadUserByUsername. Tuy nhiên hàm này trả về 1 object có data type là UserDetails. Để lấy được nó, ta có thể lấy bằng cách sử dụng repository gọi đến database, ở đây là UserInfoRepository. Thông thường mọi người sẽ sử dụng @Autowired để inject repository vào đây, tuy nhiên khi dùng thì Intellij sẽ cảnh báo "Field injection is not recommend" còn tại sao lại not recommend thì mình cũng chưa hiểu rõ :v Thay vào đó ta sẽ khai báo bằng constructor như sau:
@Component
@RequiredArgsConstructor
public class UserInfoService implements UserDetailsService { private final UserInfoRepository repository; public UserInfoService(UserInfoRepository userInfoRepository) { this.repository = userInfoRepository; }
.....
Ở đây mình có dùng @RequiredArgsConstructor để rút gọn phần boilerplate code ^^
Tiếp theo, mọi người sẽ thấy mình gọi hàm findByName(username), để có thể sử dụng được hàm này, chúng ta cần tạo 1 abstract method trong UserRepository như sau:
Optional<UserInfo> findByName(String username);
6. Convert UserInfo sang UserDetails
Tuy nhiên, ở đây chúng ta mới có 1 object userInfo có data type là UserInfo, chúng ta cần phải convert từ UserInfo sang UserDetails. Để có thể làm được điều đó, chúng ta cần tạo 1 class mới là UserInfoUserDetails và implements UserDetails và khai báo các properties chúng ta muốn convert sang UserDetails bao gồm: username, password và authorities. Sau khi implements ta có class UserInfoUserDetails như sau:
package com.example.springsecurity.config; import com.example.springsecurity.entity.UserInfo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails; import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors; /** * Created by HachNV on 29/05/2023 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserInfoUserDetails implements UserDetails { private String name; private String password; private List<GrantedAuthority> authorities; public UserInfoUserDetails(UserInfo userInfo) { name = userInfo.getName(); password = userInfo.getPassword(); authorities = Arrays.stream(userInfo.getRoles().split(",")) .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } @Override public String getPassword() { return password; } @Override public String getUsername() { return name; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; }
}
Có thể thấy authorities ở đây được khai báo bằng List<GrantedAuthority> bởi vì trong thực tế 1 người có thể có nhiều role ví dụ vừa là role "ADMIN" vừa là role"MODERATOR"
Như vậy thì hàm loadUserByUsername sẽ như sau:
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Optional<UserInfo> userInfo = repository.findByName(username); return userInfo.map(UserInfoUserDetails::new) .orElseThrow(() -> new UsernameNotFoundException("user not found: " + username)); }
7. Thêm user vào database
Về cơ bản, chúng ta đã config xong phần lấy thông tin user từ database, tiếp theo chúng ta sẽ sửa phần hard code trong SecurityConfig. Bây giờ chúng ta sẽ sử dụng UserInfoService đã được tạo ra bằng cách implments UserDetailsService, và sẽ trở thành như sau:
....
@RequiredArgsConstructor
public class SecurityConfig { private final UserInfoRepository repository; @Bean public UserDetailsService userDetailsService() {
// UserDetails admin = User.withUsername("hach")
// .password(encoder.encode("hacheery"))
// .roles("ADMIN")
// .build();
// UserDetails user = User.withUsername("user")
// .password(encoder.encode("pwd1"))
// .roles("USER")
// .build();
// return new InMemoryUserDetailsManager(admin, user); return new UserInfoService(repository); }
....
Từ bây giờ, các request sẽ được gửi đến UserInfoService và fetch user object từ db bằng username được truyền vào từ param. Để có thể làm được việc đó, đầu tiên chúng ta cần phải thêm user vào trong db. Như thường lệ thì chúng ta sẽ tạo repository(ở đây dùng luôn UserInfoRepository đã tạo sẵn ở trên) sau đó tạo UserService gọi đến repo và cuối cùng là viết UserController để lấy dữ liệu từ client và chuyển tiếp cho service. Dưới đây là code hoàn chỉnh:
UserService:
package com.example.springsecurity.service; import com.example.springsecurity.entity.UserInfo;
import com.example.springsecurity.repository.UserInfoRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; /** * Created by HachNV on 31/05/2023 */
@Service
public record UserService(UserInfoRepository repository, PasswordEncoder passwordEncoder) { public String addUser(UserInfo userInfo) { userInfo.setPassword(passwordEncoder.encode(userInfo.getPassword())); repository.save(userInfo); return "Thêm user thành công!"; }
}
UserController:
package com.example.springsecurity.controller; import com.example.springsecurity.entity.UserInfo;
import com.example.springsecurity.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; /** * Created by HachNV on 31/05/2023 */
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController { private final UserService userService; @PostMapping("/new") public String addUser(@RequestBody UserInfo userInfo) { return userService.addUser(userInfo); }
}
Tuy nhiên, có một vấn đề ở đây, nếu chúng ta gọi đến địa chỉ http://localhost:8080/user/new thì sẽ lại trả về trang login, trong khi cần phải có tài khoản thì mới login được?? =))) chính vì vậy chúng ta cần phải thay đổi config một chút:
...
.requestMatchers("/hello", "/user/new").permitAll()
...
Ở đây chúng ta thêm 1 patterns nữa là "/user/new" để không cần login khi truy cập.
Để có thể test được thì chúng ta sẽ sử dụng Postman với url: http://localhost:8080/user/new method là post như hình:
Mọi người check kỹ thông tin như hình nhé, tránh việc không call được api ^^
Sau khi call api ta có kết quả như sau:
6. Config AuthenticationProvider
Như vậy thì bây giờ đã có thể sử dụng dynamic account rồi, chúng ta sẽ thực hiện login bằng tài khoản đã tạo ở trên. Tuy nhiên, khi login thì lại có thông báo như sau:
UsernamePasswordAuthenticationToken là loại token mặc định được Spring Security sử dụng để đại diện cho xác thực dựa trên tên người dùng và mật khẩu. Khi chúng ta cố gắng xác thực bằng tên người dùng và mật khẩu, Spring Security tìm kiếm AuthenticationProvider thích hợp để xử lý quá trình xác thực. Tuy nhiên ở đây chúng ta chưa tạo ra nó cho nên chúng ta sẽ tạo ra 1 instance như sau:
@Bean public AuthenticationProvider authenticationProvider(){ DaoAuthenticationProvider authenticationProvider=new DaoAuthenticationProvider(); authenticationProvider.setUserDetailsService(userDetailsService()); authenticationProvider.setPasswordEncoder(passwordEncoder()); return authenticationProvider; }
7. Thành quả
Như vậy sau khi login và đăng nhập vào địa chỉ: http://localhost:8080/customer/all ta sẽ có được data như hình dưới đây:
Vậy là về cơ bản chúng ta đã hoàn thành việc implement security trong Spring boot 3. Hy vọng bài viết này sẽ giúp đỡ mọi người trong việc tìm hiểu về Spring boot 3 và Spring security 6. Nếu có vấn đề gì trong quá trình thực hiện, mọi người có thể comment, mình sẽ hỗ trợ trong khả năng của mình ạ.
Nguồn tham khảo: https://youtu.be/R76S0tfv36w Link github source code: https://github.com/hachnv8/spring-security