參照上次的例子:Spring Security簡單應用https://blog.csdn.net/xxkalychen/article/details/102498016
這個例子的應用場景是在調用接口的時候發現不能通過驗證就自動跳轉到登錄頁面登錄,但是登陸成功之後,身份驗證信息只是倚靠session來驗證。在分佈式服務中,我們每一次調用接口都會做驗證,這就需要我們在登錄時要獲取一個jwt,每次調用業務接口的時候都要帶着這個jwt的。於是,我們需要把Spring Security和JWT結合起來運用。
一、我們在上次構建的Security項目的基礎之上來擴展,我們把端口修改爲6007
二、添加JWT的相關pom依賴
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- GSON -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.5</version>
</dependency>
三、先領域建模。我們需要一個記錄用戶信息的模型User.java,爲了簡單一點,我們把用戶數據和用戶登錄參數的模型設計爲一致,實際上應該是不一致的。
package com.chris.sec.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* create by: Chris Chan
* create on: 2019/9/26 8:09
* use for:
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
private String username;
private String password;
}
把兩個常量收集起來
AppConstans.java
/**
* create by: Chris Chan
* create on: 2019/9/26 8:08
* use for: 全局常量
*/
public interface AppConstants {
//JWT默認指紋
String JWT_SECRET_KEY_NORMAL="JKHDWNCJKLFKKJHDKEKLLDKJKLFHJKHSGHAJKFJLNKSFLLKDLKL";
//響應頭Authorization
String KEY_AUTHORIZATION="Authorization";
}
四、由於要用到JWT,我們用一個JWTUtils來構建和解析JWT
package com.chris.sec.utils;
import com.chris.sec.consts.AppConstants;
import com.chris.sec.model.UserInfo;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
/**
* create by: Chris Chan
* create on: 2019/9/27 13:02
* use for: JWT處理工具
*/
public class JWTUtils {
/**
* 通過User對象構建jwt
* 此處是專用的
*
* @param userInfo
* @return
*/
public static String createJWTByLoginUser(UserInfo userInfo, String[] authorities) {
//構建有效載荷有關用戶信息的部分
Map<String, Object> claimsMap = new HashMap<>(16);
//目前把用戶名存進去
claimsMap.put("username", userInfo.getUsername());
claimsMap.put("authorities", authorities);
//構建JWT
String token = Jwts.builder()
.setId(UUID.randomUUID().toString())
.setClaims(claimsMap)
.setSubject(userInfo.getUsername())
.setExpiration(Date.from(LocalDateTime.now().plusDays(1).toInstant(ZoneOffset.of("+8"))))
.signWith(SignatureAlgorithm.HS512, AppConstants.JWT_SECRET_KEY_NORMAL)
.compact();
return token;
}
/**
* 解析token
*
* @param token
* @return
*/
public static Claims parseTokenForBearer(String token) {
return Jwts.parser()
.setSigningKey(AppConstants.JWT_SECRET_KEY_NORMAL)
.parseClaimsJws(token.replace("Bearer ", ""))
.getBody();
}
/**
* 從token中解析出username
*
* @param token
* @return
*/
public static String getUsernameFromToken(String token) {
Object usernameObj = parseTokenForBearer(token).get("username");
return String.valueOf(usernameObj);
}
/**
* 獲取權限列表
*
* @param token
* @return
*/
public static String[] getAuthorityFromToken(String token) {
Object obj = parseTokenForBearer(token).get("authorities");
ArrayList<String> authoritiyList = (ArrayList<String>) obj;
String[] authorities = new String[authoritiyList.size()];
return authoritiyList.toArray(authorities);
}
}
五、這一次不用默認的登錄處理,而是使用我們自己編寫的處理邏輯,我們需要寫兩個過濾器,用來處理登錄成功構建頭部Authorization和調用接口時驗證JWT。
package com.chris.sec.config.filter;
import com.chris.sec.model.UserInfo;
import com.chris.sec.service.UserService;
import com.chris.sec.utils.JWTUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
/**
* create by: Chris Chan
* create on: 2019/9/26 11:43
* use for: 自動登錄處理 接收並解析用戶信息
*/
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
public JWTLoginFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
//從權限認證信息中心讀取用戶名和密碼
UserInfo userInfo = new ObjectMapper().readerFor(UserInfo.class).readValue(request.getInputStream());
//創建身份驗證token
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userInfo.getUsername(), userInfo.getPassword(), Collections.emptyList());
//創建用戶身份驗證信息
Authentication authenticate = authenticationManager.authenticate(token);
return authenticate;
} catch (IOException e) {
//e.printStackTrace();
throw new RuntimeException(e);
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
//super.successfulAuthentication(request, response, chain, authResult);
//如果驗證通過就創建token返回
//獲取用戶信息
User user = (User) authResult.getPrincipal();
//構建jwt
String username = user.getUsername();
String token = JWTUtils.createJWTByLoginUser(new UserInfo(username, user.getPassword()), UserService.getAuthorities(username));
//把JWT添加到響應頭
response.addHeader("Authorization", "Bearer " + token);
}
}
package com.chris.sec.config.filter;
import com.chris.sec.consts.AppConstants;
import com.chris.sec.utils.JWTUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.util.StringUtils;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
/**
* create by: Chris Chan
* create on: 2019/9/27 10:38
* use for:
*/
public class JWTAuthenticationFilter extends BasicAuthenticationFilter {
public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
//獲取頭部Authorization信息
String token = request.getHeader(AppConstants.KEY_AUTHORIZATION);
//如果沒找到湖綜合不是Bearer開頭就放到下一個過濾器處理,比如登錄接口就不帶
if (StringUtils.isEmpty(token) || !token.startsWith("Bearer")) {
chain.doFilter(request, response);
return;
}
//創建驗證信息
UsernamePasswordAuthenticationToken authenlication = createAuthenlication(request);
//放入上下文進行驗證
SecurityContextHolder.getContext().setAuthentication(authenlication);
chain.doFilter(request, response);
}
/**
* 創建身份驗證信息
*
* @param request
* @return
*/
private UsernamePasswordAuthenticationToken createAuthenlication(HttpServletRequest request) {
String token = request.getHeader(AppConstants.KEY_AUTHORIZATION);
if (StringUtils.isEmpty(token)) {
return null;
}
String username = String.valueOf(JWTUtils.getUsernameFromToken(token));
if (!StringUtils.isEmpty(username)) {
return new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
}
return null;
}
}
六、SecurityConfig需要修改,把兩個過濾器加進去,同時去掉登錄設置,應爲登錄已經被接口替代了,驗證過濾器也做了處理。
package com.chris.sec.config;
import com.chris.sec.config.filter.JWTAuthenticationFilter;
import com.chris.sec.config.filter.JWTLoginFilter;
import com.chris.sec.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* create by: Chris Chan
* create on: 2019/10/11 11:48
* use for:
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserService userService;
@Autowired
PasswordEncoder passwordEncoder;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.authorizeRequests()
.antMatchers("/api/user/login").permitAll()
.antMatchers("/api/user/loginBasic").permitAll()
.anyRequest().authenticated()
.and()
.addFilter(new JWTLoginFilter(authenticationManager()))
.addFilter(new JWTAuthenticationFilter(authenticationManager()))
.csrf().disable();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userService)
.passwordEncoder(passwordEncoder);
}
}
七、UserService需要對接口業務邏輯做處理,所以也需要做一些增加和修改。
package com.chris.sec.service;
import com.chris.sec.consts.AppConstants;
import com.chris.sec.model.UserInfo;
import com.chris.sec.utils.JWTUtils;
import io.jsonwebtoken.impl.TextCodec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
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.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* create by: Chris Chan
* create on: 2019/10/11 12:31
* use for:
*/
@Service
public class UserService implements UserDetailsService {
private static Map<String, String> userMap = new HashMap<>(16);
private static Map<String, String> userAuthMap = new HashMap<>(16);
@Autowired
PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (StringUtils.isEmpty(username)) {
return null;
}
UserInfo userInfo = findUser(username);
if (null == userInfo) {
return null;
}
return new User(username, userInfo.getPassword(), getAuthorityList(username));
}
/**
* 返回密碼
* 這個方法可以假設是從數據庫dao層獲取到用戶信息
*
* @param username
* @return
*/
private UserInfo findUser(String username) {
if (null == userMap) {
userMap = new HashMap<>(16);
}
//內置幾個用戶
if (userMap.size() == 0) {
userMap.put("zhangsanfeng", passwordEncoder.encode("123123"));
userMap.put("lisifu", passwordEncoder.encode("123123"));
userMap.put("songzihao", passwordEncoder.encode("123123"));
}
String password = userMap.get(username);
if (StringUtils.isEmpty(password)) {
return null;
}
return new UserInfo(username, password);
}
/**
* 獲取用戶權限
* 這個方法也可以在數據庫中查詢
*
* @param username
* @return
*/
public static String[] getAuthorities(String username) {
if (null == userAuthMap) {
userAuthMap = new HashMap<>(16);
}
//內置幾個用戶權限
if (userAuthMap.size() == 0) {
userAuthMap.put("zhangsanfeng", "ROLE_ADMIN,ROLE_USER");
userAuthMap.put("lisifu", "ROLE_ADMIN,ROLE_USER");
userAuthMap.put("songzihao", "ROLE_SYS,ROLE_ADMIN,ROLE_USER");
}
return userAuthMap.get(username).split(",");
}
/**
* 獲取用戶權限集合
*
* @param username
* @return
*/
public static List<GrantedAuthority> getAuthorityList(String username) {
return AuthorityUtils.createAuthorityList(getAuthorities(username));
}
/**
* 自定義Basic方式登錄
* 涉及上次面試題
*
* @param request
* @param response
* @return
*/
public String loginBasic(HttpServletRequest request, HttpServletResponse response) throws IOException {
//從頭部取得數據
String authToken = request.getHeader(AppConstants.KEY_AUTHORIZATION);
if (StringUtils.isEmpty(authToken) || !authToken.startsWith("Basic ")) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
//解析
String loginInfo = TextCodec.BASE64URL.decodeToString(authToken.replace("Basic ", ""));
String[] split = loginInfo.split(":");
if (split.length != 2) {
throw new RuntimeException("token格式不正確");
}
String username = split[0];
String password = split[1];
return login(new UserInfo(username, password), response);
}
/**
* 自定義登錄
*
* @param loginUser
* @param response
* @return
*/
public String login(UserInfo loginUser, HttpServletResponse response) throws IOException {
String username = loginUser.getUsername();
UserInfo userInfo = findUser(username);
//測試 上一次面試相關 如果沒找到用戶 拋401
if (null == userInfo) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return null;
}
//如果密碼不匹配 拋403
if (!passwordEncoder.matches(loginUser.getPassword(), userInfo.getPassword())) {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return null;
}
return JWTUtils.createJWTByLoginUser(loginUser, getAuthorities(username));
}
}
八、爲了比較接近真實業務,登錄接口我們需要自己來寫,所以我們新建一個UserApi.java來處理用戶登錄
package com.chris.sec.api;
import com.chris.sec.model.UserInfo;
import com.chris.sec.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* create by: Chris Chan
* create on: 2019/9/26 7:59
* use for:
*/
@RestController
@RequestMapping("/api/user")
public class UserApi {
@Autowired
UserService userService;
@PostMapping("/login")
public String login(@RequestBody UserInfo userInfo, HttpServletResponse response) throws IOException {
String token = userService.login(userInfo, response);
return token;
}
/**
* basic登錄
* 涉及上次的面試題
* 登錄信息不在參數中,而在頭部信息的Authrication中,以Basic 開頭,用戶名和密碼用冒號連接,並做過Base64編碼
*
* @return
*/
@PostMapping("/loginBasic")
public String loginBasic(HttpServletRequest request, HttpServletResponse response) throws IOException {
return userService.loginBasic(request, response);
}
}
註明一下,loginBasic是有一次面試的一個機試題,當時理解錯了,花了很多時間也沒弄出效果。後來自己有設計了一下,感覺並不難。於是在這裏測試一下,不影響本次的主題測試。
九、測試一下。由於我們需要post請求,所以我們在PostMan中來進行測試。
1. 測試登錄
我們獲得了正確的JWT.
2. 測試接口
我們需要在Auth中天上我們剛纔獲取的token,可以看到能夠正確請求接口。如果填錯了會報錯。
說明一下,這個token如果不填就不會驗證,因爲過濾器沒有處理。建議登錄的時候適用basic驗證,然後對不帶token的進行限制,也可以根據url進行區別限制。在此不多做邏輯。
我們還可以用這種方式來調用接口:
一般前端調用的主要手段,就是這樣構建頭信息。
十、由於在權限驗證過濾器中構建用戶信息是使用了
return new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
這一行沒有帶權限信息,導致具有權限約束的接口都不能調用。我們把把上句改成:
return new UsernamePasswordAuthenticationToken(username, null, AuthorityUtils.createAuthorityList(JWTUtils.getAuthorityFromToken(token)));
你就會發現,用zhangsanfeng和lisifu這兩個賬號都不能訪問test2這個接口,而用songzihao這個用戶就可以。
十一、最後測試一下我的loginBasic接口
調用時成功的。可是一般前端怎麼構建呢?
我們需要把用戶名和密碼用username:password的結構進行base64url編碼,然後放入頭部信息。
我們通過一個網站來對用戶名和密碼進行編碼http://tool.chinaz.com/Tools/Base64.aspx:
然後以請求頭構建的方式來請求:
請求成功。用這個token請求test接口是沒問題的,請求test2接口報403,因爲沒權限,換個用戶songzihao試試就可以。
附:最後還是把權限驗證的過濾器做了修改:
package com.chris.sec.config.filter;
import com.chris.sec.consts.AppConstants;
import com.chris.sec.service.UserService;
import com.chris.sec.utils.JWTUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.util.StringUtils;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* create by: Chris Chan
* create on: 2019/9/27 10:38
* use for:
*/
public class JWTAuthenticationFilter extends BasicAuthenticationFilter {
public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
//除非是登錄,否則Authorization頭信息必須要有,沒有就拋異常
StringBuffer requestURL = request.getRequestURL();
if (requestURL.toString().contains("/api/user/login")) {
chain.doFilter(request, response);
return;
}
//創建驗證信息
UsernamePasswordAuthenticationToken authenlication = createAuthenlication(request);
//放入上下文進行驗證
SecurityContextHolder.getContext().setAuthentication(authenlication);
chain.doFilter(request, response);
}
/**
* 創建身份驗證信息
*
* @param request
* @return
*/
private UsernamePasswordAuthenticationToken createAuthenlication(HttpServletRequest request) {
String token = request.getHeader(AppConstants.KEY_AUTHORIZATION);
if (StringUtils.isEmpty(token)) {
return null;
}
String username = String.valueOf(JWTUtils.getUsernameFromToken(token));
if (!StringUtils.isEmpty(username)) {
return new UsernamePasswordAuthenticationToken(username, null, UserService.getAuthorityList(username));
}
return null;
}
}
這樣一來,根據url放過了登錄接口,其他的接口一律要檢查,沒有token也不行,全部提示無權限。
實際業務中,登錄獲取JWT和調用接口驗證JWT是分佈不同的服務中的,兩個過濾器的業務也就應該分開設置。不過有關JWT的相關信息應該統一。