SpringBoot2+SpringSecurity整合JWT,前後端分離的API權限認證框架搭建手冊

前言

之前又用到JWT,但是基本都是別人搭建,直接使用,有什麼可以優化的,也不知道,所以還是要自己實踐一遍,實踐才能出真理。也看到很多整合的文章,有些說的細緻但是版本太了,有些說的不夠詳細,而且細節也挺多的,紙上得來終覺淺,絕知此事要躬行,所以自己動手實踐了一下。

本文應該有很多中叫法的,可以叫:

  • springboot + spring security + jwt 實現api權限控制
  • 基於SpringSecurity和JWT的用戶訪問認證和授權
  • 使用JWT和Spring Security保護REST API
  • SpringSecurity整合JWT
  • Spring Security Tutorial: REST Security with JWT
  • 使用JWT保護你的Spring Boot應用 - Spring Security實戰

Why&What JWT?

在這裏插入圖片描述
使用 JWT 做權限驗證,相比傳統Session的優點是,Session 需要佔用大量服務器內存,並且在多服務器時就會涉及到Session共享問題,對手機等移動端訪問時就比較麻煩,因此前後端分離的項目很多都用JWT來做。

  • JWT無需存儲在服務器(不使用Session/Cookie),不佔用服務器資源(也就是Stateless無狀態的),也就不存在多服務器共享Session的問題
  • 使用簡單,用戶在登錄成功拿到 Token後,一般訪問需要權限的請求時,在Header附上Token即可。

開源項目

代碼已經上傳GITHUB,查看源碼更方便。

文章從以下部分來描述:

  • Maven 依賴
  • Starter 啓動器
  • Application 配置
  • Entity 實體類
  • Config 配置類
  • Service&Controller 服務和控制器類
  • Result 運行效果

Maven依賴

主要是securityjwt兩個包,其他的用springboot+web那些就可以了.

  • org.springframework.boot.spring-boot-starter-security
  • io.jsonwebtoken.jjwt
		<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security -->
		<dependency>
		    <groupId>org.springframework.boot</groupId>
		    <artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt</artifactId>
			<version>0.9.1</version>
		</dependency>

Starter啓動類

沒什麼特別的

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SecurityApp {
	public static void main(String[] args) {
		SpringApplication.run(SecurityApp.class,args);
	}
}

Application.yml

主要看jwt部分,都是動態參數,從配置讀取是比較合理的,代碼不用改動。

  • authentication.path,使用username+password去獲取認證的URL路徑,這裏設置爲/auth
  • header,JWT的頭部,一般是Authorization開頭"Bearer token"的格式
  • secret,加密的密鑰,隨便設置一個唄,在集羣環境下,只要保證這個一致就行了,生成一些複雜點的即可。
server:
  port: 9999
  servlet:
      context-path: /security
tomcat:
    remote-ip-header: x-forward-for
    uri-encoding: UTF-8
    max-threads: 10
    background-processor-delay: 30
#here is the importance configs of JWT
jwt:
  route:
    authentication:
      path: /auth
  header: Authorization
  expiration: 604800
  secret: zhengkai.blog.csdn.net

Entity實體類

  • JwtRequest,請求封裝,主要包含usernamepassword字段,前臺發後臺的時候發json,@RequestBody可以直接轉換。
  • JwtResponse,相應封裝,主要包含jwttoken字段,直接返回對象即可。
  • JwtUser,實現了UserDetails接口,JWT用戶相關封裝。
@Data
public class JwtRequest implements Serializable {
	private static final long serialVersionUID = 1L;
	private String username;
	private String password;
}
@Data
public class JwtResponse implements Serializable {
	private static final long serialVersionUID = 1L;
	private String jwttoken;
	public JwtResponse(String jwttoken) {
		this.jwttoken = jwttoken;
	}
}
@Data
public class JwtUser implements UserDetails {

	private static final long serialVersionUID = 1L;

	private final String id;
	private final String username;
	private final String password;
	private final Collection<? extends GrantedAuthority> authorities;
	private final boolean enabled;

	public JwtUser(
			String id,
			String username,
			String password, List<String> authorities,
			boolean enabled
			) {
		this.id = id;
		this.username = username;
		this.password = password;
		this.authorities = mapToGrantedAuthorities(authorities);
		this.enabled = enabled;
	}
	public JwtUser(
			String id,
			String username,
			String password, String authoritie,
			boolean enabled
			) {
		this.id = id;
		this.username = username;
		this.password = password;
		this.authorities = mapToGrantedAuthorities(authoritie);
		this.enabled = enabled;
	}
	private List<GrantedAuthority> mapToGrantedAuthorities(List<String> authorities) {
        return authorities.stream()
                .map(authority -> new SimpleGrantedAuthority(authority))
                .collect(Collectors.toList());
    }
	private List<GrantedAuthority> mapToGrantedAuthorities(String authoritie) {
        return Arrays.asList(new SimpleGrantedAuthority(authoritie));
    }
	public String getId() {
		return id;
	}

	@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 String getPassword() {
		return password;
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return authorities;
	}

	@Override
	public boolean isEnabled() {
		return enabled;
	}

}

Config配置類

  • JwtTokenUtil,JWT工具類,生成/驗證/是否過期token 。
  • WebSecurityConfig,Security配置類,啓用URL過濾,設置PasswordEncoder密碼加密類。
  • JwtRequestFilter,過濾JWT請求,驗證"Bearer token"格式,校驗Token是否正確
  • JwtAuthenticationEntryPoint,實現AuthenticationEntryPoint類,返回認證不通過的信息
@Component
public class JwtTokenUtil implements Serializable {
    private static final long serialVersionUID = -2550185165626007488L;
    public static final long JWT_TOKEN_VALIDITY = 5 * 60 * 60;
    
    @Value("${jwt.secret}")
    private String secret;
    
    //retrieve username from jwt token
    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }
    //retrieve expiration date from jwt token
    public Date getExpirationDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }
    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }
    //for retrieveing any information from token we will need the secret key
    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
    }
    //check if the token has expired
    private Boolean isTokenExpired(String token) {
        final Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }
    //generate token for user
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return doGenerateToken(claims, userDetails.getUsername());
    }
    //while creating the token -
//1. Define  claims of the token, like Issuer, Expiration, Subject, and the ID
//2. Sign the JWT using the HS512 algorithm and secret key.
//3. According to JWS Compact Serialization(https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#section-3.1)
//   compaction of the JWT to a URL-safe string
    private String doGenerateToken(Map<String, Object> claims, String subject) {
        return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000))
                .signWith(SignatureAlgorithm.HS512, secret).compact();
    }
    //validate token
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	@Autowired
	private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
	@Autowired
	private JwtUserDetailsService jwtUserDetailsService;
	@Autowired
	private JwtRequestFilter jwtRequestFilter;


	@Value("${jwt.header}")
	private String tokenHeader;

	@Value("${jwt.route.authentication.path}")
	private String authenticationPath;

	@Autowired
	public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
		// configure AuthenticationManager so that it knows from where to load
		// user for matching credentials
		// Use BCryptPasswordEncoder
		auth.userDetailsService(jwtUserDetailsService).passwordEncoder(passwordEncoder());
	}
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	@Bean
	@Override
	public AuthenticationManager authenticationManagerBean() throws Exception {
		return super.authenticationManagerBean();
	}
	@Override
	protected void configure(HttpSecurity httpSecurity) throws Exception {
		System.out.println("authenticationPath:"+authenticationPath);
		// We don't need CSRF for this example
		httpSecurity.csrf().disable()
		.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and().sessionManagement()
		.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
		// dont authenticate this particular request
		.and()
		.authorizeRequests()
		.antMatchers(authenticationPath).permitAll()
		.anyRequest().authenticated()

		.and()	
		.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);

		// disable page caching
		httpSecurity
		.headers()
		.frameOptions().sameOrigin()  // required to set for H2 else H2 Console will be blank.
		.cacheControl();
	}
	@Override
	public void configure(WebSecurity web) throws Exception {
		// AuthenticationTokenFilter will ignore the below paths
		web
		.ignoring()
		.antMatchers(
				HttpMethod.POST,
				authenticationPath
				)

		// allow anonymous resource requests
		.and()
		.ignoring()
		.antMatchers(
				HttpMethod.GET,
				"/",
				"/*.html",
				"/favicon.ico",
				"/**/*.html",
				"/**/*.css",
				"/**/*.js"
				);
	}
}
import java.io.IOException;
import java.io.Serializable;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {
	private static final long serialVersionUID = 1L;

	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException authException) throws IOException {
		response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
	}
}

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import com.softdev.system.demo.service.JwtUserDetailsService;

import io.jsonwebtoken.ExpiredJwtException;

@Component
public class JwtRequestFilter extends OncePerRequestFilter {
	@Autowired
	private JwtUserDetailsService jwtUserDetailsService;
	
	@Autowired
	private JwtTokenUtil jwtTokenUtil;
	
	@Value("${jwt.route.authentication.path}")
	private String authenticationPath;
	
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws ServletException, IOException {
		final String requestTokenHeader = request.getHeader("Authorization");
		String username = null;
		String jwtToken = null;
		// JWT報文表頭的格式是"Bearer token". 去除"Bearer ",直接獲取token
		// only the Token
		if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
			jwtToken = requestTokenHeader.substring(7);
			try {
				username = jwtTokenUtil.getUsernameFromToken(jwtToken);
			} catch (IllegalArgumentException e) {
				System.out.println("Unable to get JWT Token");
			} catch (ExpiredJwtException e) {
				System.out.println("JWT Token has expired");
			}
		} else {
			logger.warn("JWT Token does not begin with Bearer String");
		}
		// Once we get the token validate it.
		if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
			UserDetails userDetails = this.jwtUserDetailsService.loadUserByUsername(username);
			// if token is valid configure Spring Security to manually set
			// authentication
			if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
				UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
						userDetails, null, userDetails.getAuthorities());
				usernamePasswordAuthenticationToken
				.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
				// After setting the Authentication in the context, we specify
				// that the current user is authenticated. So it passes the
				// Spring Security Configurations successfully.
				SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
			}
		}
		chain.doFilter(request, response);
	}
}

Service與Controller

  • JwtUserDetailsService,實現UserDetailsService,重寫loadUserByUsername方法,返回隨機生成的user,pass是密碼,這裏固定生成的,如果你自己需要定製查詢user的方法,請改造這裏。(如果你沒用hutool這個這麼好用的庫,那麼可以用其他方法代替隨機值,也可以從數據庫查詢/緩存查詢,都在這改造)
  • JwtAuthenticationController,包含登陸查看token的方法
import org.apache.commons.lang.StringUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import com.softdev.system.demo.entity.JwtUser;

import cn.hutool.core.util.RandomUtil;
/**
 * JwtUserDetailsService
 *	 	實現UserDetailsService,重寫loadUserByUsername方法
 *  	返回隨機生成的user,pass是密碼,這裏固定生成的
 *  	如果你自己需要定製查詢user的方法,請改造這裏
 * @author zhengkai.blog.csdn.net
 */
@Service
public class JwtUserDetailsService implements UserDetailsService{
	@Override
	public UserDetails loadUserByUsername(String username) {
		String pass = new BCryptPasswordEncoder().encode("pass");
		if (StringUtils.isNotEmpty(username)&&username.contains("user")) {
			return new JwtUser(RandomUtil.randomString(8), username,pass,"USER", true);
		} else {
			throw new UsernameNotFoundException(String.format("No user found with username '%s'.", username));
		}
	}
}


import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.softdev.system.demo.config.JwtTokenUtil;
import com.softdev.system.demo.entity.JwtRequest;
import com.softdev.system.demo.entity.JwtResponse;
import com.softdev.system.demo.entity.JwtUser;
import com.softdev.system.demo.service.JwtUserDetailsService;

/**
 * JwtAuthenticationController
 * 	包含登陸和查看token的方法
 * @author zhengkai.blog.csdn.net
 */
@RestController
@CrossOrigin
public class JwtAuthenticationController {
	@Autowired
	private AuthenticationManager authenticationManager;
	@Autowired
	private JwtTokenUtil jwtTokenUtil;
	@Autowired
	private JwtUserDetailsService userDetailsService;
	
	@Value("${jwt.header}")
	private String tokenHeader;

	@PostMapping("${jwt.route.authentication.path}")
	public ResponseEntity<?> createAuthenticationToken(@RequestBody JwtRequest authenticationRequest) throws Exception {
		System.out.println("username:"+authenticationRequest.getUsername()+",password:"+authenticationRequest.getPassword());
		authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword());
		final UserDetails userDetails = userDetailsService
				.loadUserByUsername(authenticationRequest.getUsername());
		final String token = jwtTokenUtil.generateToken(userDetails);
		return ResponseEntity.ok(new JwtResponse(token));
	}
	
	private void authenticate(String username, String password) throws Exception {
		try {
			authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
		} catch (DisabledException e) {
			throw new Exception("USER_DISABLED", e);
		} catch (BadCredentialsException e) {
			throw new Exception("INVALID_CREDENTIALS", e);
		}
	}

	@GetMapping("/token")
	public JwtUser getAuthenticatedUser(HttpServletRequest request) {
		String token = request.getHeader(tokenHeader).substring(7);
		String username = jwtTokenUtil.getUsernameFromToken(token);
		JwtUser user = (JwtUser) userDetailsService.loadUserByUsername(username);
		return user;
	}

}

Result運行效果

  1. 授權接口

localhost:9999/security/auth

在這裏插入圖片描述
請求數據json:

{
	"username":"users",
	"password":"pass"
}

響應數據:

{
    "jwttoken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VycyIsImV4cCI6MTU2MzUzNTU1NiwiaWF0IjoxNTYzNTE3NTU2fQ.rpK2URUu6e_JdxQR0v6ClFz1O_-w4SRDlBqZKd2FYpl7WIjczlopFvAl7yShwyrudPhLCt8hdgqNzO4Wqu71Dw"
}
  1. token信息接口

localhost:9999/security/token

在這裏插入圖片描述
請求頭(注意是Header不是Body,格式Bearer+空格+Token):

Authorization:Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VycyIsImV4cCI6MTU2MzUzNTU1NiwiaWF0IjoxNTYzNTE3NTU2fQ.rpK2URUu6e_JdxQR0v6ClFz1O_-w4SRDlBqZKd2FYpl7WIjczlopFvAl7yShwyrudPhLCt8hdgqNzO4Wqu71Dw

響應數據:

{
    "accountNonExpired": true,
    "accountNonLocked": true,
    "authorities": [
        {
            "authority": "USER"
        }
    ],
    "credentialsNonExpired": true,
    "enabled": true,
    "id": "vmw553ro",
    "password": "$2a$10$W/cPKmgwZv4gVMKO4pvUsOr9fusTTOwiHu1QSSlMICB42hU.AjpAO",
    "username": "users"
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章