Trong bài viết này mình sẽ hướng dẫn mọi người sử dụng Spring Boot JWT Authentication với các chức năng cơ bản:
- Người dùng có thể đăng ký tài khoản mới hoặc đăng nhập bằng username và password.
- Với vai trò của người dùng (quản trị viên, điều hành viên, người dùng), ứng dụng cho phép người dùng truy cập tài nguyên theo vai trò và quyền hạn.
Mọi người có thể tìm đọc các bài viết liên quan tại:
Đây là bản demo ứng dụng Spring Boot với cơ sở dữ liệu MySQL và thử nghiệm Rest Apis với Postman.
Methods Urls Actions
POST /api/auth/signup signup new account
POST /api/auth/signin login an account
GET /api/test/all retrieve public content
GET /api/test/user access User’s content
GET /api/test/mod access Moderator’s content
GET /api/test/admin access Admin’s content
Spring Boot Signup và Login (JWT Authentication Flow)
Luồng xử lý người dùng đăng ký tài khoản mới, đăng nhập và xác thực.
Chuỗi token JWT phải được thêm vào Authorization Header ở mỗi HTTP Request nếu Client truy cập vào tài nguyên được bảo vệ. Trong trường hợp Token hết hạn Client sẽ cần Refresh Token:
Chi tiết sẽ có ở bài viết Spring Boot Security Refresh Token.
Spring Boot Server Architecture with Spring Security
WebSecurityConfigurerAdapter
là class chốt chặn triển khai bảo mật. Nó cung cấp các cấu hìnhHttpSecurity
để cấu hình cors, csrf, quản lý phiên, các quy tắc cho các tài nguyên cần được bảo vệ. Ta cũng có thể mở rộng và tùy chỉnh cấu hình mặc định có chứa các phần tử bên dưới.UserDetailsService
interface có một phương thức để lấy thông tin người dùng bằng username và trả về một đối tượngUserDetails
mà Spring Security có thể sử dụng để xác thực và xác nhận.UserDetails
chứa các thông tin cần thiết (như: username, password, authorities - quyền hạn) để xây dựng một đối tượng Authentication.- Từ thông tin username và password trong request đăng nhập tạo ra instance của
UsernamePasswordAuthenticationToken
là implement của interfaceAuthentication
(để biết điều này phải đọc sâu vào trong code mới thấy được). RồiAuthenticationManager
sẽ sử dụng instance này để xác thực tài khoản đăng nhập.
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken
và
public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer
AuthenticationManager
sử dụngDaoAuthenticationProvider
cùng vớiUserDetailsService
&PasswordEncoder
validate instance củaUsernamePasswordAuthenticationToken
. Nếu thành công,AuthenticationManager
trả về một đối tượngAuthentication
đầy đủ thông tin (bao gồm cả các quyền).OncePerRequestFilter
là Filter thực thi một lần duy nhất cho mỗi Request tới API. Nó cung cấp một phương thứcdoFilterInternal()
- trong phương thức này ta sẽ triển khai: parse và validate chuỗi JWT, lấy thông tin người dùng (sử dụngUserDetailsService
), kiểm tra Authorization.AuthenticationEntryPoint
là class handle các lỗi xác thựcAuthenticationException
Repository chứa UserRepository
& RoleRepository
để làm việc với Database, sẽ được nhập vào Controller.
Controller nhận và xử lý yêu cầu sau khi nó được Filter bởi OncePerRequestFilter
.
AuthController
xử lý các yêu cầu đăng ký / đăng nhậpTestController
xử lý các yêu cầu cần có quyền truy cập các phương thức được bảo vệ với các xác thực dựa trên vai trò (roles) và quyền hạn (permissions).
Package security
WebSecurityConfig
extendsWebSecurityConfigurerAdapter
UserDetailsServiceImpl
implementsUserDetailsService
UserDetailsImpl
implementsUserDetails
AuthenticationEntryPointHandler
implementsAuthenticationEntryPoint
AuthenticationFilter
extendsOncePerRequestFilter
JwtUtils
cung cấp các phương thức generating, parsing, validating JWT
Model
Có 3 model chính là user
, role
, permission
. Một người dùng có nhiều vai trò (bảng quan hệ user-role
), một vai trò có nhiều quyền hạn (bảng quan hệ role-permission
).
User
package tiendv.example.model;
// imports @Entity
@Table( name = "user", uniqueConstraints = { @UniqueConstraint(columnNames = "username"), @UniqueConstraint(columnNames = "email") })
public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank @Size(max = 20) private String username; @NotBlank @Size(max = 50) @Email private String email; @NotBlank @Size(max = 120) private String password; @ManyToMany(cascade = CascadeType.REFRESH, fetch = FetchType.EAGER) @JoinTable( name = "user_role", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id")) private Set<Role> roles = new HashSet<>(); public User() { } public User(String username, String email, String password) { this.username = username; this.email = email; this.password = password; } // getter/setter
}
Role
package tiendv.example.model;
// imports @Entity
@Table(name = "role")
public class Role { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(length = 20) private String name; @ManyToMany(cascade = CascadeType.REFRESH, fetch = FetchType.EAGER) @JoinTable(name = "role_permission", joinColumns = {@JoinColumn(name = "role_id")}, inverseJoinColumns = {@JoinColumn(name = "permission_id")}) private Set<Permission> permissions = new HashSet<>(); public Role() { } // getter/setter
}
Permission
package tiendv.example.model;
// imports @Entity
@Table(name = "permission")
public class Permission { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(length = 20) private String name; public Permission() { } public Permission(Integer id, String name) { this.id = id; this.name = name; } // getter/setter
}
Implement Repositories
UserRepository
package tiendv.example.repository;
// imports @Repository
public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByUsername(String username); Boolean existsByUsername(String username); Boolean existsByEmail(String email);
}
RoleRepository
package tiendv.example.repository;
import java.util.Optional;
// imports @Repository
public interface RoleRepository extends JpaRepository<Role, Long> { Optional<Role> findByName(String name);
}
Configure Spring Security
WebSecurityConfig
package tiendv.example.security;
// imports @Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity( // securedEnabled = true, // jsr250Enabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired UserDetailsServiceImpl userDetailsService; @Autowired private AuthenticationEntryPointHandler unauthorizedHandler; @Bean public AuthenticationFilter authenticationJwtTokenFilter() { return new AuthenticationFilter(); } @Override public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception { authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { http.cors().and().csrf().disable() .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .authorizeRequests().antMatchers("/api/auth/**").permitAll() .antMatchers("/api/test/**").permitAll() .anyRequest().authenticated(); http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); }
}
@EnableWebSecurity
cho phép Spring tìm và tự động áp dụng class này là global Web security.@EnableGlobalMethodSecurity
cung cấp AOP security trên phương thức (Aspect Oriented Programming - phương pháp lập trình hướng khía cạnh). Cho phép inject 2 annotation là@PreAuthorize
và@PostAuthorize
trên các phương cần bảo về quyền hạn truy cập. Tham khảo thêm tại đây- Việc override lại phương thức
configure(HttpSecurity http)
trongWebSecurityConfigurerAdapter
interface, cho phép Spring security cấu hình CORS và CSRF khi chúng ta yêu cầu người dùng phải xác thực. Chúng ta cũng có thể thêm các class Filter và các clas Handle Exception(trong ví dụ này làAuthenticationFilter
filter trướcUsernamePasswordAuthenticationFilter
vàAuthenticationEntryPoint
để handler exception). - Spring security sẽ load thông tin chi tiết của người dùng để thực hiện xác thực và phân quyền (authentication và authorization). Vì vậy cần sử dụng
UserDetailsService
để triển khai. - Implementation của
UserDetailsService
sẽ được sử dụng để cấu hìnhDaoAuthenticationProvider
bởi phương thứcAuthenticationManagerBuilder.userDetailsService()
. - Chúng ta cũng cần
PasswordEncoder
cung cấp choDaoAuthenticationProvider
để thực hiện encode password. Nếu không, mặc định nó sẽ sử dụng kiểu plain text.
Implement UserDetails & UserDetailsService
Nếu như quá trình xác thực thành công, chúng ta có thể lấy được thông tin username, password, authorities của người dùng từ đối tượng Authentication
.
Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(username, password) ); UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// userDetails.getUsername()
// userDetails.getPassword()
// userDetails.getAuthorities()
Nếu chúng ta muốn lưu nhiều thông tin hơn như id, email, phoneNumber, ..., chúng ta có thể tạo một implementation của UserDetails
interface.
UserDetailsImpl
package tiendv.example.security.service;
// imports public class UserDetailsImpl implements UserDetails { private static final long serialVersionUID = 1L; private Long id; private String username; private String email; @JsonIgnore private String password; private Collection<? extends GrantedAuthority> authorities; public UserDetailsImpl(Long id, String username, String email, String password, Collection<? extends GrantedAuthority> authorities) { this.id = id; this.username = username; this.email = email; this.password = password; this.authorities = authorities; } public static UserDetailsImpl build(User user) { List<GrantedAuthority> authorities = new ArrayList<>(); user.getRoles().forEach(r -> { authorities.add(new SimpleGrantedAuthority(r.getName())); r.getPermissions().forEach(p -> authorities.add(new SimpleGrantedAuthority(p.getName()))); }); return new UserDetailsImpl( user.getId(), user.getUsername(), user.getEmail(), user.getPassword(), authorities); } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } public Long getId() { return id; } public String getEmail() { return email; } @Override public String getPassword() { return password; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UserDetailsImpl user = (UserDetailsImpl) o; return Objects.equals(id, user.id); }
}
Như đã nói trước đó Spring security sử dụng UserDetailsService
interface để load thông tin đối tượng UserDtails
. Đây là code thư viện UserDetailsService
interface của Spring security, nó chỉ chứa 1 phương thức duy nhất:
package org.springframework.security.core.userdetails; public interface UserDetailsService { UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}
Bây giờ chúng ta sẽ implement và override phương thức loadUserByUsername()
để trả về class UserDetailsImpl
chứa đầy đủ thông tin hơn (đây là implementation của interface UserDetails
, xem lại code bên trên).
UserDetailsServiceImpl
package tiendv.example.security.service;
// imports @Service
public class UserDetailsServiceImpl implements UserDetailsService { @Autowired UserRepository userRepository; /** * Override phương thức trong class UserDetailsService * * @param username * @return UserDetailsImpl là implements của UserDetails (UserDetails là đối tượng Spring security sử dụng để authen và authorize) * @throws UsernameNotFoundException */ @Override @Transactional public UserDetailsImpl loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("User Not Found with username: " + username)); return UserDetailsImpl.build(user); }
}
Trong đoạn code trên, chúng ta sử dụng UserRepositoty
để lấy full thông tin của User, sau đó build đối tượng UserDetailsImpl
(chính là UserDetails
).
Filter Requests
Để định nghĩa ra một Filter (bộ lọc) thực thi trên mỗi request. Ta tạo ra một class filter kế thừa từ class OncePerRequestFilter
và override phương thức doFilterInternal()
.
AuthenticationFilter
package tiendv.example.security.jwt;
// imports public class AuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtils jwtUtils; @Autowired private UserDetailsServiceImpl userDetailsService; private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String token = getTokenFromRequest(request); if (token != null && jwtUtils.validateJwtToken(token)) { String username = jwtUtils.getUserNameFromJwtToken(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { logger.error("Cannot set user authentication: {}", e); } filterChain.doFilter(request, response); } private String getTokenFromRequest(HttpServletRequest request) { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { return token.substring(7, token.length()); } return null; }
}
Trong phương thức doFilterInternal()
, ví dụ mình đang xử lý như sau (mọi người có thể xử lý khác theo logic của mọi người):
- Lấy JWT từ Authorization trong header của request (bỏ prefix 'Bearer')
- Nếu request có JWT thì validate, parse, check hết hạn, lấy
username
trong JWT này - Từ thông tin
username
lấy ra thông tinUserDetails
và tạo ra đối tượngAuthentication
- Sử dụng phương thức
setAuthentication(authentication)
set thông tin UserDetails vào SecurityContext. Kể từ sau đó, chúng ta có thể lấy thông tinUserDetails
bất cứ khi nào thông qua SecurityContext như sau:
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); // userDetails.getUsername()
// userDetails.getPassword()
// userDetails.getAuthorities()
// hoặc các fiels khác định nghĩa trong UserDetailsImpl (là implementation của UserDetails)
JWT Utility
Đây là class support việc:
- generate
JWT
từ username, date, expriation, secret,... - parser
JWT
, getusername
trongJWT
- validate
JWT
Nói chung là việc gen JWT, parse, validate... mọi người có thể suwrdungj thuật toán khác hay custom lại theo cách riêng của mọi người.
JwtUtils
package tiendv.example.security.jwt;
// import @Component
public class JwtUtils { private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class); @Value("${jwt.app.jwtSecret}") private String jwtSecret; @Value("${jwt.app.jwtExpirationMs}") private int jwtExpirationMs; public String generateJwtToken(UserDetailsImpl userDetails) { return Jwts.builder() .setSubject((userDetails.getUsername())) .setIssuedAt(new Date()) .setExpiration(generateExpirationDate()) .signWith(SignatureAlgorithm.HS512, jwtSecret) .compact(); } public Date generateExpirationDate() { return new Date(System.currentTimeMillis() + jwtExpirationMs); } public Claims getClaimsFromJwtToken(String token) { return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody(); } private boolean isTokenExpired(Claims claims) { return claims.getExpiration().after(new Date()); } public String getUserNameFromJwtToken(String token) { Claims claims = getClaimsFromJwtToken(token); if (claims != null && isTokenExpired(claims)) { return claims.getSubject(); } return null; } public boolean validateJwtToken(String authToken) { try { Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken); return true; } catch (SignatureException e) { logger.error("Invalid JWT signature: {}", e.getMessage()); } catch (MalformedJwtException e) { logger.error("Invalid JWT token: {}", e.getMessage()); } catch (ExpiredJwtException e) { logger.error("JWT token is expired: {}", e.getMessage()); } catch (UnsupportedJwtException e) { logger.error("JWT token is unsupported: {}", e.getMessage()); } catch (IllegalArgumentException e) { logger.error("JWT claims string is empty: {}", e.getMessage()); } return false; }
}
Handle Authentication Exception
Cuối cùng là handle exception. Chúng ta tạo một class là AuthenticationEntryPointHandler
implements interface AuthenticationEntryPoint
. Sau đó override phương thức commence()
. Phương thức này sẽ được trigger bất cứ khi nào request đến server không được xác thực hoặc xảy ra throw AuthenticationException
AuthenticationEntryPointHandler
package tiendv.example.security.jwt;
// imports @Component
public class AuthenticationEntryPointHandler implements AuthenticationEntryPoint { private static final Logger logger = LoggerFactory.getLogger(AuthenticationEntryPointHandler.class); @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { logger.error("Unauthorized error: {}", authException.getMessage()); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized"); }
}
HttpServletResponse.SC_UNAUTHORIZED
là lỗi 401. Nó chỉ ra rằng HTTP request không được xác thực.
Kịch bản
Request
- LoginRequest: {username, password }
- SignupRequest: {username, email, password }
Responses:
- JwtResponse: {token, type, id, username, email, roles }
- MessageResponse: {message }
RestAPIs Controllers
Controller cung cấp api đăng ký và đăng nhập
/api/auth/signup
- Kiểm tra tồn tại username/email hay chưa
- Tạo người dùng (User) mới (mặc định sẽ là ROLE_USER nếu không được chỉ định)
- Lưu User vào database
/api/auth/signin
- Xác thực người dùng {username, pasword }
- update Authentication object trong SecurityContext
- generate JWT
- Lấy thông tin UserDetails từ Authentication object
- Response thông tin JWT và thông tin UserDetails
AuthController
package tiendv.example.controller;
// imports @CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/api/auth")
public class AuthController { @Autowired AuthenticationManager authenticationManager; @Autowired UserRepository userRepository; @Autowired RoleRepository roleRepository; @Autowired PasswordEncoder encoder; @Autowired JwtUtils jwtUtils; @PostMapping("/signin") public ResponseEntity<?> login(@Valid @RequestBody LoginRequest loginRequest) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword())); SecurityContextHolder.getContext().setAuthentication(authentication); UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); String jwt = jwtUtils.generateJwtToken(userDetails); List<String> roles = userDetails.getAuthorities().stream() .map(item -> item.getAuthority()) .collect(Collectors.toList()); return ResponseEntity.ok(new JwtResponse(jwt, userDetails.getId(), userDetails.getUsername(), userDetails.getEmail(), roles)); } @PostMapping("/signup") public ResponseEntity<?> register(@Valid @RequestBody SignupRequest signUpRequest) { if (userRepository.existsByUsername(signUpRequest.getUsername())) { return ResponseEntity .badRequest() .body(new MessageResponse("Error: Username is already taken!")); } if (userRepository.existsByEmail(signUpRequest.getEmail())) { return ResponseEntity .badRequest() .body(new MessageResponse("Error: Email is already in use!")); } User user = new User(signUpRequest.getUsername(), signUpRequest.getEmail(), encoder.encode(signUpRequest.getPassword())); Set<String> asignRoles = signUpRequest.getRole(); Set<Role> roles = new HashSet<>(); // Nếu không truyền thì set role mặc định là ROLE_USER if (asignRoles == null) { Role userRole = roleRepository.findByName(RoleEnum.ROLE_USER.getRole()) .orElseThrow(() -> new RuntimeException("Error: Role is not found.")); roles.add(userRole); } else { asignRoles.forEach(role -> { switch (role) { case "admin": Role adminRole = roleRepository.findByName(RoleEnum.ROLE_ADMIN.getRole()) .orElseThrow(() -> new RuntimeException("Error: Role is not found.")); roles.add(adminRole); break; case "mod": Role modRole = roleRepository.findByName(RoleEnum.ROLE_MODERATOR.getRole()) .orElseThrow(() -> new RuntimeException("Error: Role is not found.")); roles.add(modRole); break; default: Role userRole = roleRepository.findByName(RoleEnum.ROLE_USER.getRole()) .orElseThrow(() -> new RuntimeException("Error: Role is not found.")); roles.add(userRole); } }); } user.setRoles(roles); userRepository.save(user); return ResponseEntity.ok(new MessageResponse("User registered successfully!")); }
}
Controller dùng để test Authorization
/api/test/all
tất cả user đều có thể truy cập (kể cả chưa được phân quyền)/api/test/user
cho những user có ROLE_USER hoặc ROLE_MODERATOR hoặc ROLE_ADMIN/api/test/mod
cho những user có ROLE_MODERATOR/api/test/admin
cho nhưng user có ROLE_ADMIN
TestController
package tiendv.example.controller;
// imports @CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/api/test")
public class TestController { @GetMapping("/all") public String allAccess() { return "Public Content."; } @GetMapping("/user") @PreAuthorize("hasRole('USER') or hasRole('MODERATOR') or hasRole('ADMIN')") public String userAccess() { return "User Content."; } @GetMapping("/mod") @PreAuthorize("hasRole('MODERATOR')") public String moderatorAccess() { return "Moderator Board."; } @GetMapping("/admin") @PreAuthorize("hasRole('ADMIN')") public String adminAccess() { return "Admin Board."; }
}
Trong source code có file README.MD
mình có để sẵn SQL tạo bảng, insert dữ liệu cần thiết. Chi tiết tại đây: Github
Run & Test
Mình sẽ tạo ra 3 user thông qua api /signup
:
- user admin với
ROLE_ADMIN
- user mod với
ROLE_MODERATOR
vàROLE_USER
,ROLE_A
,ROLE_B
,ROLE_C
- user tiendv với
ROLE_USER
,ROLE_A
,ROLE_B
/singup
:
/signin
với user dvtien (ROLE_USER
, ROLE_A
, ROLE_B
, ROLE_C
):
Sử dụng jwt của user dvtien truy cập resource /api/test/user
:
Sử dụng jwt của user dvtien truy cập vào resource /api/test/all
:
Sử dụng jwt của user dvtien truy cập vào resource /api/test/admin
:
Sử dụng jwt của user dvtien truy cập vào resource /api/test/mod
:
Không sử dụng jwt truy cập vào resource /api/test/user
:
Tổng kết
Trên đây là hướng dẫn để mọi người hiểu hơn về cấu hình Spring security để xác thực và phân quyền người dùng.
Hy vọng mọi người sẽ hiểu được ý tưởng tổng thể của bài viết và áp dụng nó vào dự án của mọi người một cách thoải mái nhất.
Follow me: thenewstack.wordpress.com