Code ví dụ Spring MVC – Spring Security JSON Web Token.
(Xem thêm: Code ví dụ Spring Boot JSON Web Token (Annotation Config))
(Xem lại: JSON Web Token là gì?)
(Xem lại: Code ví dụ, tạo RESTful Web Service với Spring MVC)
Trong bài này mình sẽ làm ví dụ về Spring Security cho những URL được dùng để gọi API (Restful Web Service)
Các công nghệ sử dụng:
- Spring 5.0.2.RELEASE
- Spring Security 5.0.2.RELEASE
- Maven
- Tomcat
- JDK 1.8
- Eclipse + Spring Tool Suite
Tạo Maven Project
Thư viện sử dụng:
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>stackjava.com</groupId> <artifactId>SpringMvcJwt</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>war</packaging> <properties> <spring.version>5.0.2.RELEASE</spring.version> <spring.security.version>5.0.2.RELEASE</spring.security.version> <jstl.version>1.2</jstl.version> </properties> <dependencies> <!-- Spring MVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- Spring Security --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-web</artifactId> <version>${spring.security.version}</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> <version>${spring.security.version}</version> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>jsp-api</artifactId> <version>2.2</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>servlet-api</artifactId> <version>2.5</version> <scope>provided</scope> </dependency> <!-- jstl for jsp page --> <dependency> <groupId>jstl</groupId> <artifactId>jstl</artifactId> <version>${jstl.version}</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.9.3</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-core</artifactId> <version>2.9.3</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-annotations</artifactId> <version>2.9.3</version> </dependency> <!-- JWT --> <dependency> <groupId>com.nimbusds</groupId> <artifactId>nimbus-jose-jwt</artifactId> <version>4.20</version> </dependency> <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.7</version> </dependency> </dependencies> </project>
Mình sử dụng thư viện nimbus-jose-jwt để tạo token (decode/encode token, chữ ký số)
File entities:
package stackjava.com.springmvcjwt.entities; import java.util.ArrayList; import java.util.List; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(value = { "roles", "authorities" }) public class User { private int id; private String username; private String password; private String[] roles; public User() { } public User(int id, String username, String password) { this.id = id; this.username = username; this.password = password; } // getter - setter public List<GrantedAuthority> getAuthorities() { List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>(); for (String role : roles) { authorities.add(new SimpleGrantedAuthority(role)); } return authorities; } }
File Controller:
package stackjava.com.springmvcjwt.controller; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @Controller public class BaseController { @RequestMapping(value = { "/login", "/" }) public String login(@RequestParam(value = "error", required = false) final String error, final Model model) { if (error != null) { model.addAttribute("message", "Login Failed!"); } return "login"; } @RequestMapping("/admin") public String admin() { return "admin"; } @RequestMapping("/logout") public String logout(final Model model) { model.addAttribute("message", "Logged out!"); return "login"; } }
package stackjava.com.springmvcjwt.controller; import java.util.List; import javax.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; import stackjava.com.springmvcjwt.entities.User; import stackjava.com.springmvcjwt.service.JwtService; import stackjava.com.springmvcjwt.service.UserService; @RestController @RequestMapping("/rest") public class UserRestController { @Autowired private JwtService jwtService; @Autowired private UserService userService; /* ---------------- GET ALL USER ------------------------ */ @RequestMapping(value = "/users", method = RequestMethod.GET) public ResponseEntity<List<User>> getAllUser() { return new ResponseEntity<List<User>>(userService.findAll(), HttpStatus.OK); } /* ---------------- GET USER BY ID ------------------------ */ @RequestMapping(value = "/users/{id}", method = RequestMethod.GET) public ResponseEntity<Object> getUserById(@PathVariable int id) { User user = userService.findById(id); if (user != null) { return new ResponseEntity<Object>(user, HttpStatus.OK); } return new ResponseEntity<Object>("Not Found User", HttpStatus.NO_CONTENT); } /* ---------------- CREATE NEW USER ------------------------ */ @RequestMapping(value = "/users", method = RequestMethod.POST) public ResponseEntity<String> createUser(@RequestBody User user) { if (userService.add(user)) { return new ResponseEntity<String>("Created!", HttpStatus.CREATED); } else { return new ResponseEntity<String>("User Existed!", HttpStatus.BAD_REQUEST); } } /* ---------------- DELETE USER ------------------------ */ @RequestMapping(value = "/users/{id}", method = RequestMethod.DELETE) public ResponseEntity<String> deleteUserById(@PathVariable int id) { userService.delete(id); return new ResponseEntity<String>("Deleted!", HttpStatus.OK); } @RequestMapping(value = "/login", method = RequestMethod.POST) public ResponseEntity<String> login(HttpServletRequest request, @RequestBody User user) { String result = ""; HttpStatus httpStatus = null; try { if (userService.checkLogin(user)) { result = jwtService.generateTokenLogin(user.getUsername()); httpStatus = HttpStatus.OK; } else { result = "Wrong userId and password"; httpStatus = HttpStatus.BAD_REQUEST; } } catch (Exception ex) { result = "Server Error"; httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; } return new ResponseEntity<String>(result, httpStatus); } }
File Service:
package stackjava.com.springmvcjwt.service; import java.util.ArrayList; import java.util.List; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import stackjava.com.springmvcjwt.entities.User; @Service public class UserService { public static List<User> listUser = new ArrayList<User>(); static { User userKai = new User(1, "kai", "123456"); userKai.setRoles(new String[] { "ROLE_ADMIN" }); User userSena = new User(2, "sena", "123456"); userSena.setRoles(new String[] { "ROLE_USER" }); listUser.add(userKai); listUser.add(userSena); } public List<User> findAll() { return listUser; } public User findById(int id) { for (User user : listUser) { if (user.getId() == id) { return user; } } return null; } public boolean add(User user) { for (User userExist : listUser) { if (user.getId() == userExist.getId() || StringUtils.equals(user.getUsername(), userExist.getUsername())) { return false; } } listUser.add(user); return true; } public void delete(int id) { listUser.removeIf(user -> user.getId() == id); } public User loadUserByUsername(String username) { for (User user : listUser) { if (user.getUsername().equals(username)) { return user; } } return null; } public boolean checkLogin(User user) { for (User userExist : listUser) { if (StringUtils.equals(user.getUsername(), userExist.getUsername()) && StringUtils.equals(user.getPassword(), userExist.getPassword())) { return true; } } return false; } }
Mình khởi tạo listUser và thực hiện truy vấn, login thêm/sửa/xóa user trên list này.
Ban đầu mình sẽ khởi tạo 2 tài khoản là kai/123456 với role ADMIN và sena/123456 với role là USER để đăng nhập và truy vấn.
(Các bạn có thể xem lại bài Code ví dụ Spring MVC + Hibernate + Maven + MySQL + Eclipse và Code ví dụ Spring Security Hibernate 5 + Database MySQL để làm việc trực tiếp với database).
package stackjava.com.springmvcjwt.service; import java.util.Date; import org.springframework.stereotype.Service; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.JWSSigner; import com.nimbusds.jose.JWSVerifier; import com.nimbusds.jose.crypto.MACSigner; import com.nimbusds.jose.crypto.MACVerifier; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; @Service public class JwtService { public static final String USERNAME = "username"; public static final String SECRET_KEY = "11111111111111111111111111111111"; public static final int EXPIRE_TIME = 86400000; public String generateTokenLogin(String username) { String token = null; try { // Create HMAC signer JWSSigner signer = new MACSigner(generateShareSecret()); JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder(); builder.claim(USERNAME, username); builder.expirationTime(generateExpirationDate()); JWTClaimsSet claimsSet = builder.build(); SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); // Apply the HMAC protection signedJWT.sign(signer); // Serialize to compact form, produces something like // eyJhbGciOiJIUzI1NiJ9.SGVsbG8sIHdvcmxkIQ.onO9Ihudz3WkiauDO2Uhyuz0Y18UASXlSc1eS0NkWyA token = signedJWT.serialize(); } catch (Exception e) { e.printStackTrace(); } return token; } private JWTClaimsSet getClaimsFromToken(String token) { JWTClaimsSet claims = null; try { SignedJWT signedJWT = SignedJWT.parse(token); JWSVerifier verifier = new MACVerifier(generateShareSecret()); if (signedJWT.verify(verifier)) { claims = signedJWT.getJWTClaimsSet(); } } catch (Exception e) { e.printStackTrace(); } return claims; } private Date generateExpirationDate() { return new Date(System.currentTimeMillis() + EXPIRE_TIME); } private Date getExpirationDateFromToken(String token) { Date expiration = null; JWTClaimsSet claims = getClaimsFromToken(token); expiration = claims.getExpirationTime(); return expiration; } public String getUsernameFromToken(String token) { String username = null; try { JWTClaimsSet claims = getClaimsFromToken(token); username = claims.getStringClaim(USERNAME); } catch (Exception e) { e.printStackTrace(); } return username; } private byte[] generateShareSecret() { // Generate 256-bit (32-byte) shared secret byte[] sharedSecret = new byte[32]; sharedSecret = SECRET_KEY.getBytes(); return sharedSecret; } private Boolean isTokenExpired(String token) { Date expiration = getExpirationDateFromToken(token); return expiration.before(new Date()); } public Boolean validateTokenLogin(String token) { if (token == null || token.trim().length() == 0) { return false; } String username = getUsernameFromToken(token); if (username == null || username.isEmpty()) { return false; } if (isTokenExpired(token)) { return false; } return true; } }
Class JwtService.java dùng để tạo và validate token.
File cấu hình spring mvc:
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xmlns:mvc="http://www.springframework.org/schema/mvc" xsi:schemaLocation="http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-4.3.xsd http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.3.xsd"> <context:component-scan base-package="stackjava.com.springmvcjwt" /> <mvc:annotation-driven /> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix"> <value>/WEB-INF/views/jsp/</value> </property> <property name="suffix"> <value>.jsp</value> </property> </bean> </beans>
File cấu hình Spring security:
<?xml version="1.0" encoding="UTF-8"?> <beans:beans xmlns="http://www.springframework.org/schema/security" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:beans="http://www.springframework.org/schema/beans" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd"> <beans:bean id="restServicesEntryPoint" class="stackjava.com.springmvcjwt.rest.RestAuthenticationEntryPoint" /> <beans:bean id="customAccessDeniedHandler" class="stackjava.com.springmvcjwt.rest.CustomAccessDeniedHandler" /> <beans:bean id="jwtAuthenticationFilter" class="stackjava.com.springmvcjwt.rest.JwtAuthenticationTokenFilter"> <beans:property name="authenticationManager" ref="authenticationManager" /> </beans:bean> <http pattern="/rest/login" security="none"/> <http pattern="/rest/**" auto-config="false" use-expressions="true" create-session="stateless" entry-point-ref="restServicesEntryPoint"> <intercept-url pattern="/rest/**" access="hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')" method="GET"/> <intercept-url pattern="/rest/**" access="hasRole('ROLE_ADMIN')" method="DELETE"/> <intercept-url pattern="/rest/**" access="hasRole('ROLE_ADMIN')" method="POST"/> <custom-filter position="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> <access-denied-handler ref="customAccessDeniedHandler"/> <csrf disabled="true" /> </http> <http auto-config="true"> <intercept-url pattern="/admin**" access="hasRole('ROLE_ADMIN')" /> <form-login login-page="/login" login-processing-url="/j_spring_security_login" default-target-url="/admin" authentication-failure-url="/login?error" username-parameter="username" password-parameter="password" /> <logout logout-url="/j_spring_security_logout" logout-success-url="/logout" delete-cookies="JSESSIONID" /> </http> <authentication-manager alias="authenticationManager"> <authentication-provider> <user-service> <user name="kai" password="{noop}123456" authorities="ROLE_ADMIN" /> </user-service> </authentication-provider> </authentication-manager> </beans:beans>
Với các url /rest/** sẽ chỉ cho phép người dùng đã đăng nhập.
Các url /rest/** với method GET (API lấy thông tin user) cho phép cả role ADMIN và USER truy cập, với các method “DELETE” và “POST” (xóa và tạo mới user) thì chỉ cho phép role ADMIN truy cập.
entry-point-ref="restServicesEntryPoint"
: bean restServicesEntryPoint
sẽ xử lý những request chưa được xác thực.
<custom-filter position="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/>
thay vì validate bằng form login, ta sử dụng bean jwtAuthenticationFilter
để thực hiện filter request.
<access-denied-handler ref="customAccessDeniedHandler"/>
: trường hợp người dùng gửi request mà không có quyền sẽ do bean customAccessDeniedHandler
xử lý (Ví dụ chỉ có role USER nhưng gửi request xóa user)
Các class filter, handler security:
package stackjava.com.springmvcjwt.rest; import java.io.IOException; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import stackjava.com.springmvcjwt.service.JwtService; import stackjava.com.springmvcjwt.service.UserService; public class JwtAuthenticationTokenFilter extends UsernamePasswordAuthenticationFilter { private final static String TOKEN_HEADER = "authorization"; @Autowired private JwtService jwtService; @Autowired private UserService userService; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String authToken = httpRequest.getHeader(TOKEN_HEADER); if (jwtService.validateTokenLogin(authToken)) { String username = jwtService.getUsernameFromToken(authToken); stackjava.com.springmvcjwt.entities.User user = userService.loadUserByUsername(username); if (user != null) { boolean enabled = true; boolean accountNonExpired = true; boolean credentialsNonExpired = true; boolean accountNonLocked = true; UserDetails userDetail = new User(username, user.getPassword(), enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, user.getAuthorities()); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetail, null, userDetail.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest)); SecurityContextHolder.getContext().setAuthentication(authentication); } } chain.doFilter(request, response); } }
package stackjava.com.springmvcjwt.rest; import java.io.IOException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; public final class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Unauthorized"); } }
package stackjava.com.springmvcjwt.rest; import java.io.IOException; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; public class CustomAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exc) throws IOException, ServletException { // Authentication auth = SecurityContextHolder.getContext().getAuthentication(); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.getWriter().write("Access Denied!"); } }
Các file views:
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> <html> <head> <title>login</title> </head> <body> <h1>Spring MVC - JSON Web Token</h1> <h2>${message}</h2> <form name='loginForm' action="<c:url value='j_spring_security_login' />" method='POST'> <table> <tr> <td>User:</td> <td><input type='text' name='username'></td> </tr> <tr> <td>Password:</td> <td><input type='password' name='password' /></td> </tr> <tr> <td colspan='2'><input name="submit" type="submit" value="login" /></td> </tr> </table> <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" /> </form> </body> </html>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> <html> <head> <title>Admin Page</title> </head> <body> <h1>Admin Page</h1> <h2>Welcome: ${pageContext.request.userPrincipal.name}</h2> <form action="<c:url value="/j_spring_security_logout" />" method="post"> <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" /> <input type="submit" value="Logout" /> </form> </body> </html>
Demo:
Login trên web với tài khoản kai/123456
Với các URL Restful Web Service mình sẽ sử dụng postman extension trên chrome để gửi các request thêm, xóa, lấy thông tin đối tượng user.
Trường hợp gửi request lấy thông tin tất cả user mà không có token đính kèm (chưa đăng nhập)
Đăng nhập với tài khoản sena/123456
Kết quả trả về 1 token
Đính kèm token vừa nhận được vào phần header rồi gửi request lấy thông tin tất cả user:
Gửi request tạo mới đối tượng user:
Vì tài khoản sena/123456 chỉ có role USER nên không thể truy cập được url /rest/users với method POST.
Đăng nhập lại với tài khoản kai/123456 và thực hiện thêm mới user:
Code ví dụ Spring MVC – Spring Security JSON Web Token stackjava.com
Okay, Done!
Download code ví dụ trên tại đây.
References:
https://www.toptal.com/java/rest-security-with-jwt-spring-security-and-java