四脱柱、優(yōu)化
優(yōu)化點(diǎn)如下:
-
兼容性問題:
- 因?yàn)榧纫嫒菰嫉顷懩K伐弹,又要兼容新建的管理員模塊,所以需要判斷是原始用戶還是管理員用戶榨为。
- 驗(yàn)證token時(shí)需要判斷是認(rèn)證服務(wù)頒發(fā)的令牌還是老登陸系統(tǒng)頒發(fā)的令牌并分別驗(yàn)證惨好。
- 分布式問題:ExceptionTransactionFilter中將request請求信息保存到session中椅邓,如果是分布式部署,會(huì)有訪問不到session的問題發(fā)生昧狮。
- 異常返回問題:資源服務(wù)器自定義異常返回。
4.1 兼容性優(yōu)化
生成令牌
邏輯
- 音箱作為第三方需要通過授權(quán)碼模式獲取令牌板壮,攜帶令牌訪問資源(用戶信息在原始的登陸模塊中)逗鸣,校驗(yàn)賬號密碼時(shí)還需要分兩種情況如下
- 密碼登陸
- 驗(yàn)證碼登陸
- 管理員需要通過密碼模式獲取令牌(管理員信息在新建的內(nèi)部用戶模塊中)
關(guān)鍵點(diǎn)
需要判斷賬號密碼屬于原始用戶的還是管理員的,當(dāng)然后端無法判斷绰精,所以可以讓登錄頁面發(fā)送請求時(shí)主動(dòng)攜帶標(biāo)識(shí)位撒璧,后端通過該標(biāo)識(shí)位來判斷。如內(nèi)部管理系統(tǒng)登陸頁面笨使,在頁面代碼中發(fā)送請求時(shí)多帶上一個(gè)標(biāo)識(shí)位參數(shù)卿樱。
代碼
先寫一個(gè)枚舉類記錄所有的類型
public enum UserTypeEnum {
ADMIN(1,"admin","管理員登錄"),
HIFUN_USERNAME(2,"hifun_username","用戶賬號密碼登錄"),
HIFUN_PHONE(3,"hifun_phone","用戶手機(jī)驗(yàn)證碼登錄");
Integer code;
String codeText;
String description;
UserTypeEnum(Integer code, String codeText, String description) {
this.code = code;
this.codeText = codeText;
this.description = description;
}
public Integer getCode() {
return code;
}
public String getCodeText() {
return codeText;
}
public String getDescription(){
return description;
}
}
注:手機(jī)驗(yàn)證碼是通過第三方平臺(tái)接口來做的,用戶相關(guān)的登陸最終都會(huì)調(diào)用原有的用戶系統(tǒng)的接口進(jìn)行驗(yàn)證硫椰。
通過Feign來調(diào)用用戶系統(tǒng)的接口
@FeignClient(name = "hifun-service-user")
public interface HifunFeign {
/**
* 驗(yàn)證密碼
*
* @param id
* @param password
* @return
*/
@PostMapping(path = "/password/check", consumes = "application/json")
String check(@RequestParam("id") Integer id, @RequestBody String password);
/**
* 根據(jù)手機(jī)號獲取用戶信息
*
* @param mobile
* @return
*/
@GetMapping(path = "/user/mobile")
String mobile(@RequestParam("mobile") String mobile);
/**
* 極驗(yàn)初始化接口
*
* @param clientType
* @param ip
* @param smsType
* @return
*/
@GetMapping(path = "/geetest")
String geetest(@RequestParam("clientType") String clientType, @RequestParam("ip") Integer ip, @RequestParam("smsType") String smsType);
/**
* 極驗(yàn)二次驗(yàn)證并發(fā)送短信驗(yàn)證碼
*
* @param geetestPO
* @return
*/
@PostMapping(path = "/geetest")
String geetest(@RequestBody GeetestPO geetestPO);
/**
* 校驗(yàn)手機(jī)和驗(yàn)證碼是否正確
*
* @return
*/
@PostMapping(path = "/login/code")
String code(@RequestBody CodePO codePO);
@PostMapping(path = "/password/findPassword")
void findPassword(@RequestBody PasswordPO passwordPO);
}
重寫MyUserDetailsServer類中的loadUserByUsername方法
@Service
@Slf4j
public class MyUserDetailsServer implements UserDetailsService {
@Resource
private HifunFeign hifunFeign;
@Resource
private UserCenterFeign userCenterFeign;
@Resource
private HttpServletRequest httpServletRequest;
/**
* 將賬號密碼和權(quán)限信息封裝到UserDetails對象中返回
*
* @param username
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// String userType = httpServletRequest.getHeader("User-Type");
String userType = httpServletRequest.getParameter("user_type");
System.out.println("*****loadUserByUsername userType:" + userType);
//查詢數(shù)據(jù)庫獲取用戶的信息
if (UserTypeEnum.ADMIN.getCodeText().equals(userType)) {
// 1.請求頭是admin繁调,查詢到管理人員數(shù)據(jù)庫
TbUserPO tbUser = userCenterFeign.getTbUser(username);
Assert.isTrue(!Objects.isNull(tbUser), "管理員不存在");
//將用戶信息添加到token中
// UserInfoDTO userInfo = BeanUtil.copyProperties(tbUser, UserInfoDTO.class);
// JSONObject userObj = new JSONObject(userInfo);
// String userStr = userObj.toString();
tbUser.setUsername(tbUser.getId().toString());
//獲取用戶的角色和權(quán)限
List<String> roleCodes = userCenterFeign.getRoleCodes(username);
List<String> authorities = userCenterFeign.getAuthorities(roleCodes);
//將用戶角色添加到用戶權(quán)限中
authorities.addAll(roleCodes);
//設(shè)置UserDetails中的authorities屬性,需要將String類型轉(zhuǎn)換為GrantedAuthority
MyUserDetails myUserDetails = BeanUtil.copyProperties(tbUser, MyUserDetails.class);
myUserDetails.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(String.join(",", authorities)));
log.info("UserDetail:" + myUserDetails);
return myUserDetails;
} else if (UserTypeEnum.HIFUN_PHONE.getCodeText().equals(userType)) {
// 2.請求頭是hifun_phone靶草,查詢火粉的用戶中心
// String userInfo;
// try {
// userInfo = hifunFeign.mobile(username);
// } catch (Exception e) {
// throw new IllegalArgumentException("該用戶不存在");
// }
// JSONObject jsonObject = new JSONObject(userInfo);
// Integer id = jsonObject.getInt("id");
return new MyUserDetails().setUsername(username).setPassword("hifun")
.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(String.join(",", "hifun")))
.setAccountNonExpired(true)
.setAccountNonLocked(true)
.setCredentialsNonExpired(true)
.setEnabled(true);
//這個(gè)User對象是校驗(yàn)client的賬號密碼時(shí)使用的蹄胰,expire、lock等信息自動(dòng)填充為true
// return new User(id.toString(), "hifun", AuthorityUtils.commaSeparatedStringToAuthorityList(String.join(",", "hifun")));
} else if (UserTypeEnum.HIFUN_USERNAME.getCodeText().equals(userType)) {
String userInfo;
try {
userInfo = hifunFeign.mobile(username);
} catch (Exception e) {
throw new IllegalArgumentException("該用戶不存在");
}
JSONObject jsonObject = new JSONObject(userInfo);
Integer id = jsonObject.getInt("id");
return new User(id.toString(), "hifun", AuthorityUtils.commaSeparatedStringToAuthorityList(String.join(",", "hifun")));
}
// else {
// return new User("test", "hifun", AuthorityUtils.commaSeparatedStringToAuthorityList(String.join(",", "hifun")));
// }
throw new IllegalArgumentException("未傳遞用戶類型或用戶類型不存在");
}
}
重寫DaoAuthenticationProvider類中的additionalAuthenticationChecks方法
@Component
public class MyDaoAuthenticationProvider extends DaoAuthenticationProvider {
@Resource
private HifunFeign hifunFeign;
@Resource
private BCryptPasswordEncoder bCryptPasswordEncoder;
public MyDaoAuthenticationProvider(UserDetailsService userDetailsService) {
super();
// 這個(gè)地方一定要對userDetailsService賦值奕翔,不然userDetailsService是null
setUserDetailsService(userDetailsService);
}
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
assert requestAttributes != null;
HttpServletRequest httpServletRequest = requestAttributes.getRequest();
HttpServletResponse httpServletResponse = requestAttributes.getResponse();
String presentedPassword = authentication.getCredentials().toString();
// Cookie[] cookies = httpServletRequest.getCookies();
// for (Cookie cookie : cookies) {
// System.out.println("*****cookie:" + cookie.getName());
// cookie.setMaxAge(0);
// cookie.setPath("/");
// assert httpServletResponse != null;
// httpServletResponse.addCookie(cookie);
// }
String userType = httpServletRequest.getParameter("user_type");
System.out.println("*****additionalAuthenticationChecks userType:" + userType);
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
// TODO 根據(jù)請求頭的用戶類型進(jìn)行查詢
if (UserTypeEnum.ADMIN.getCodeText().equals(userType)) {
// 1.請求頭是admin裕寨,查詢到管理人員數(shù)據(jù)庫
System.out.println("user_type是admin,查詢到管理人員數(shù)據(jù)庫");
if (!bCryptPasswordEncoder.matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
} else if (UserTypeEnum.HIFUN_PHONE.getCodeText().equals(userType)) {
// 2.請求頭是hifun_phone派继,校驗(yàn)手機(jī)驗(yàn)證碼是否正確
System.out.println("user_type是hifun_phone宾袜,校驗(yàn)手機(jī)驗(yàn)證碼是否正確");
String phone = userDetails.getUsername();
CodePO codePO = new CodePO(httpServletRequest.getParameter("app")
, (long) IpUtils.getLongIp(), phone, ""
, Long.valueOf(httpServletRequest.getParameter("terminal"))
, httpServletRequest.getParameter("uuid"), presentedPassword);
System.out.println("*****additionalAuthenticationChecks codePO:" + codePO);
try {
String code = hifunFeign.code(codePO);
System.out.println("*****additionalAuthenticationChecks code:" + code);
} catch (Exception e) {
logger.debug("Authentication failed: password does not match stored value");
try {
httpServletRequest.setAttribute("message", e.getMessage());
httpServletRequest.getRequestDispatcher("/uaa/error").forward(httpServletRequest, httpServletResponse);
} catch (Exception ex) {
e.printStackTrace();
throw new IllegalArgumentException(e.getMessage());
}
throw new IllegalArgumentException("停止程序直接返回結(jié)果");
// throw new BadCredentialsException(messages.getMessage(
// "AbstractUserDetailsAuthenticationProvider.badCredentials",
// "Bad credentials"));
}
} else if (UserTypeEnum.HIFUN_USERNAME.getCodeText().equals(userType)) {
// 3.請求頭是hifun_username,校驗(yàn)手機(jī)密碼是否正確
System.out.println("user_type是hifun_username驾窟,校驗(yàn)手機(jī)密碼是否正確");
HashMap<Object, Object> map = new HashMap<>();
map.put("password", presentedPassword);
System.out.println("*****additionalAuthenticationChecks id:" + userDetails.getUsername() + " --- password:" + presentedPassword);
String check = hifunFeign.check(Integer.parseInt(userDetails.getUsername()), new JSONObject(map).toString());
JSONObject jsonObject = new JSONObject(check);
JSONObject data = jsonObject.getJSONObject("data");
Integer success = data.getInt("success");
if (success == 0) {
logger.debug("Authentication failed: password does not match stored value");
try {
httpServletRequest.setAttribute("message", "賬號密碼錯(cuò)誤庆猫!");
httpServletRequest.getRequestDispatcher("/uaa/error").forward(httpServletRequest, httpServletResponse);
} catch (Exception e) {
e.printStackTrace();
throw new IllegalArgumentException(e.getMessage());
}
throw new IllegalArgumentException("停止程序直接返回結(jié)果");
// throw new BadCredentialsException(messages.getMessage(
// "AbstractUserDetailsAuthenticationProvider.badCredentials",
// "Bad credentials"));
}
}
String type = httpServletRequest.getHeader("type");
if ("option".equals(type)) {
System.out.println("*****type:" + type);
try {
httpServletRequest.setAttribute("message", "驗(yàn)證成功!");
httpServletRequest.getRequestDispatcher("/uaa/success").forward(httpServletRequest, httpServletResponse);
throw new IllegalArgumentException("停止程序直接返回結(jié)果");
} catch (Exception e) {
e.printStackTrace();
throw new IllegalArgumentException(e.getMessage());
}
// } else {
// Cookie[] cookies = httpServletRequest.getCookies();
// for (Cookie cookie : cookies) {
// System.out.println("*****cookie:" + cookie.getName());
// cookie.setMaxAge(0);
// cookie.setPath("/");
// assert httpServletResponse != null;
// httpServletResponse.addCookie(cookie);
// }
// Cookie session = new Cookie("SESSION", null);
// session.setMaxAge(0);
// session.setPath("/");
// System.out.println("覆蓋cookie");
// httpServletResponse.addCookie(session);
}
}
}
注:在RequestMatcher中可以對請求參數(shù)進(jìn)行校驗(yàn)
校驗(yàn)令牌
邏輯
- 原始用戶模塊有自己的jwt_token纫普,通過該token從菜譜中獲取數(shù)據(jù)阅悍。因此需要通過火粉的公鑰驗(yàn)證該token是否有效,且給該用戶相應(yīng)的權(quán)限以訪問菜譜的接口昨稼。
- 管理員是標(biāo)準(zhǔn)的oauth_token节视,不需要大的改動(dòng)。
關(guān)鍵點(diǎn)
- 需要從請求中獲取火粉或管理員的token假栓。
- 需要修改token認(rèn)證過程的源碼寻行,解析token并根據(jù)各自的令牌特點(diǎn)進(jìn)行區(qū)分并驗(yàn)證該token是否有效,并將token信息提取出來匾荆。
代碼
首先進(jìn)入OAuth2AuthenticationProcessingFilter的doFilter方法中拌蜘,因?yàn)樵加脩舻恼埱箢^不同杆烁,首先改寫獲取token的方法。
@Component
public class MyTokenExtractor implements TokenExtractor {
private final static Log logger = LogFactory.getLog(BearerTokenExtractor.class);
@Override
public Authentication extract(HttpServletRequest request) {
String tokenValue = extractToken(request);
if (tokenValue != null) {
PreAuthenticatedAuthenticationToken authentication = new PreAuthenticatedAuthenticationToken(tokenValue, "");
return authentication;
}
return null;
}
protected String extractToken(HttpServletRequest request) {
// first check the header...
String token = extractHeaderToken(request);
// bearer type allows a request parameter as well
if (token == null) {
logger.debug("Token not found in headers. Trying request parameters.");
token = request.getParameter(OAuth2AccessToken.ACCESS_TOKEN);
if (token == null) {
logger.debug("Token not found in request parameters. Not an OAuth2 request.");
}
else {
request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_TYPE, OAuth2AccessToken.BEARER_TYPE);
}
}
return token;
}
/**
* Extract the OAuth bearer token from a header.
*
* @param request The request.
* @return The token, or null if no OAuth authorization header was supplied.
*/
protected String extractHeaderToken(HttpServletRequest request) {
//1.首先看是否是火粉的token
String hifunToken = request.getHeader((String)AuthEnum.HIFUN.getCodeText());
if (!Objects.isNull(hifunToken)) {
return hifunToken;
}
//2.如果不是简卧,則檢查是否是MCook的token
Enumeration<String> headers = request.getHeaders("Authorization");
while (headers.hasMoreElements()) { // typically there is only one (most servers enforce that)
String value = headers.nextElement();
if ((value.toLowerCase().startsWith(OAuth2AccessToken.BEARER_TYPE.toLowerCase()))) {
String authHeaderValue = value.substring(OAuth2AccessToken.BEARER_TYPE.length()).trim();
// Add this here for the auth details later. Would be better to change the signature of this method.
request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_TYPE,
value.substring(0, OAuth2AccessToken.BEARER_TYPE.length()).trim());
int commaIndex = authHeaderValue.indexOf(',');
if (commaIndex > 0) {
authHeaderValue = authHeaderValue.substring(0, commaIndex);
}
return authHeaderValue;
}
}
return null;
}
}
在MyJwtAccessTokenConverter 中兔魂,其他不變,使用自定義的MyJwtHelper類的decodeAndVerify方法對token進(jìn)行驗(yàn)證举娩。
@Component
public class MyJwtAccessTokenConverter extends JwtAccessTokenConverter {
private static final Log logger = LogFactory.getLog(JwtAccessTokenConverter.class);
private AccessTokenConverter tokenConverter = new DefaultAccessTokenConverter();
private JwtClaimsSetVerifier jwtClaimsSetVerifier = new NoOpJwtClaimsSetVerifier();
private JsonParser objectMapper = JsonParserFactory.create();
private String verifierKey = new RandomValueStringGenerator().generate();
private Signer signer = new MacSigner(verifierKey);
private String signingKey = verifierKey;
private SignatureVerifier verifier;
@Resource
private PublicKey publicKey;
@Override
protected Map<String, Object> decode(String token) {
try {
Jwt jwt = MyJwtHelper.decodeAndVerify(token, verifier, publicKey);
String claimsStr = jwt.getClaims();
Map<String, Object> claims = objectMapper.parseMap(claimsStr);
if (claims.containsKey(EXP) && claims.get(EXP) instanceof Integer) {
Integer intValue = (Integer) claims.get(EXP);
claims.put(EXP, new Long(intValue));
}
this.getJwtClaimsSetVerifier().verify(claims);
return claims;
}
catch (Exception e) {
throw new InvalidTokenException("Cannot convert access token to JSON", e);
}
}
public void afterPropertiesSet() throws Exception {
if (verifier != null) {
// Assume signer also set independently if needed
return;
}
SignatureVerifier verifier = new MacSigner(verifierKey);
try {
verifier = new RsaVerifier(verifierKey);
}
catch (Exception e) {
logger.warn("Unable to create an RSA verifier from verifierKey (ignoreable if using MAC)");
}
// Check the signing and verification keys match
if (signer instanceof RsaSigner) {
byte[] test = "src/test".getBytes();
try {
verifier.verify(test, signer.sign(test));
logger.info("Signing and verification RSA keys match");
}
catch (InvalidSignatureException e) {
logger.error("Signing and verification RSA keys do not match");
}
}
else if (verifier instanceof MacSigner) {
// Avoid a race condition where setters are called in the wrong order. Use of
// == is intentional.
Assert.state(this.signingKey == this.verifierKey,
"For MAC signing you do not need to specify the verifier key separately, and if you do it must match the signing key");
}
this.verifier = verifier;
}
public void setVerifier(SignatureVerifier verifier) {
this.verifier = verifier;
}
public void setVerifierKey(String key) {
this.verifierKey = key;
}
private boolean isPublic(String key) {
return key.startsWith("-----BEGIN");
}
private class NoOpJwtClaimsSetVerifier implements JwtClaimsSetVerifier {
@Override
public void verify(Map<String, Object> claims) throws InvalidTokenException {
}
}
}
自定義的MyJwtHelper類析校,主要是decodeAndVerify方法。如果沒有jti铜涉,說明是火粉的token智玻,用火粉的公鑰驗(yàn)證,同時(shí)需要將資源權(quán)限芙代、用戶權(quán)限等信息添加到token中吊奢;如果有jti,就是oauth2的標(biāo)準(zhǔn)token纹烹。
package oauth2.config.auth.rewrite;
import cn.hutool.json.JSONObject;
import io.jsonwebtoken.*;
import oauth2.common.AuthEnum;
import org.bouncycastle.util.encoders.UTF8;
import org.springframework.security.jwt.*;
import org.springframework.security.jwt.Jwt;
import org.springframework.security.jwt.crypto.sign.InvalidSignatureException;
import org.springframework.security.jwt.crypto.sign.SignatureVerifier;
import java.nio.CharBuffer;
import java.security.PublicKey;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import static org.springframework.security.jwt.codec.Codecs.*;
import static org.springframework.security.jwt.codec.Codecs.utf8Decode;
/**
* @Description:
* @Author: CJ
* @Data: 2020/9/19 17:03
*/
public class MyJwtHelper {
static byte[] PERIOD = utf8Encode(".");
public static Jwt decode(String token) {
int firstPeriod = token.indexOf('.');
int lastPeriod = token.lastIndexOf('.');
if (firstPeriod <= 0 || lastPeriod <= firstPeriod) {
throw new IllegalArgumentException("JWT must have 3 tokens");
}
CharBuffer buffer = CharBuffer.wrap(token, 0, firstPeriod);
// TODO: Use a Reader which supports CharBuffer
JwtHeader header = JwtHeaderHelper.create(buffer.toString());
buffer.limit(lastPeriod).position(firstPeriod + 1);
byte[] claims = b64UrlDecode(buffer);
boolean emptyCrypto = lastPeriod == token.length() - 1;
byte[] crypto;
if (emptyCrypto) {
if (!"none".equals(header.parameters.alg)) {
throw new IllegalArgumentException(
"Signed or encrypted token must have non-empty crypto segment");
}
crypto = new byte[0];
}
else {
buffer.limit(token.length()).position(lastPeriod + 1);
crypto = b64UrlDecode(buffer);
}
return new JwtImpl(header, claims, crypto);
}
public static Jwt decodeAndVerify(String token, SignatureVerifier verifier, PublicKey publicKey) {
System.out.println("*****進(jìn)入自定義的token認(rèn)證方法");
Jwt jwt = decode(token);
String claims = jwt.getClaims();
JSONObject jsonObject = new JSONObject(claims);
String jti = jsonObject.getStr("jti");
if (Objects.isNull(jti)) {
//1.如果沒有jti页滚,說明是火粉的token,用火粉的公鑰驗(yàn)證
try {
Jwts.parserBuilder().setSigningKey(publicKey).build().parseClaimsJws(token);
String userId = jsonObject.getStr("userId");
jsonObject.remove("userId");
jsonObject.putOpt("user_name", userId);
jsonObject.putOpt("aud", AuthEnum.AUD.getCodeText());
jsonObject.putOpt("scope", AuthEnum.SCOPE.getCodeText());
jsonObject.putOpt("authorities", AuthEnum.AUTHORITIES.getCodeText());
if(jwt instanceof JwtImpl) {
JwtImpl jwtImpl = (JwtImpl) jwt;
JwtHeader header = jwtImpl.header();
byte[] crypto = jwtImpl.getCrypto();
claims = jsonObject.toString();
jwt = new JwtImpl(header, claims, crypto);
}
} catch (JwtException e) {
throw new InvalidSignatureException("RSA Signature did not match content");
}
} else {
//2.否則就是oauth2的標(biāo)準(zhǔn)token
jwt.verifySignature(verifier);
}
return jwt;
}
}
/**
* Helper object for JwtHeader.
*
* Handles the JSON parsing and serialization.
*/
class JwtHeaderHelper {
static JwtHeader create(String header) {
byte[] bytes = b64UrlDecode(header);
return new JwtHeader(bytes, parseParams(bytes));
}
static HeaderParameters parseParams(byte[] header) {
Map<String, String> map = parseMap(utf8Decode(header));
return new HeaderParameters(map);
}
private static Map<String, String> parseMap(String json) {
if (json != null) {
json = json.trim();
if (json.startsWith("{")) {
return parseMapInternal(json);
}
else if (json.equals("")) {
return new LinkedHashMap<String, String>();
}
}
throw new IllegalArgumentException("Invalid JSON (null)");
}
private static Map<String, String> parseMapInternal(String json) {
Map<String, String> map = new LinkedHashMap<String, String>();
json = trimLeadingCharacter(trimTrailingCharacter(json, '}'), '{');
for (String pair : json.split(",")) {
String[] values = pair.split(":");
String key = strip(values[0], '"');
String value = null;
if (values.length > 0) {
value = strip(values[1], '"');
}
if (map.containsKey(key)) {
throw new IllegalArgumentException("Duplicate '" + key + "' field");
}
map.put(key, value);
}
return map;
}
private static String strip(String string, char c) {
return trimLeadingCharacter(trimTrailingCharacter(string.trim(), c), c);
}
private static String trimTrailingCharacter(String string, char c) {
if (string.length() >= 0 && string.charAt(string.length() - 1) == c) {
return string.substring(0, string.length() - 1);
}
return string;
}
private static String trimLeadingCharacter(String string, char c) {
if (string.length() >= 0 && string.charAt(0) == c) {
return string.substring(1);
}
return string;
}
private static byte[] serializeParams(HeaderParameters params) {
StringBuilder builder = new StringBuilder("{");
appendField(builder, "alg", params.alg);
if (params.typ != null) {
appendField(builder, "typ", params.typ);
}
for (Map.Entry<String, String> entry : params.map.entrySet()) {
appendField(builder, entry.getKey(), entry.getValue());
}
builder.append("}");
return utf8Encode(builder.toString());
}
private static void appendField(StringBuilder builder, String name, String value) {
if (builder.length() > 1) {
builder.append(",");
}
builder.append("\"").append(name).append("\":\"").append(value).append("\"");
}
}
/**
* Header part of JWT
*/
class JwtHeader implements BinaryFormat {
private final byte[] bytes;
final HeaderParameters parameters;
/**
* @param bytes the decoded header
* @param parameters the parameter values contained in the header
*/
JwtHeader(byte[] bytes, HeaderParameters parameters) {
this.bytes = bytes;
this.parameters = parameters;
}
@Override
public byte[] bytes() {
return bytes;
}
@Override
public String toString() {
return utf8Decode(bytes);
}
}
class HeaderParameters {
final String alg;
final Map<String, String> map;
final String typ = "JWT";
HeaderParameters(String alg) {
this(new LinkedHashMap<String, String>(Collections.singletonMap("alg", alg)));
}
HeaderParameters(Map<String, String> map) {
String alg = map.get("alg"), typ = map.get("typ");
if (typ != null && !"JWT".equalsIgnoreCase(typ)) {
throw new IllegalArgumentException("typ is not \"JWT\"");
}
map.remove("alg");
map.remove("typ");
this.map = map;
if (alg == null) {
throw new IllegalArgumentException("alg is required");
}
this.alg = alg;
}
}
class JwtImpl implements Jwt {
final JwtHeader header;
private final byte[] content;
private final byte[] crypto;
private String claims;
/**
* @param header the header, containing the JWS/JWE algorithm information.
* @param content the base64-decoded "claims" segment (may be encrypted, depending on
* header information).
* @param crypto the base64-decoded "crypto" segment.
*/
JwtImpl(JwtHeader header, byte[] content, byte[] crypto) {
this.header = header;
this.content = content;
this.crypto = crypto;
claims = utf8Decode(content);
}
JwtImpl(JwtHeader header, String claims, byte[] crypto) {
this.header = header;
this.crypto = crypto;
this.claims = claims;
content = utf8Encode(claims);
}
/**
* Validates a signature contained in the 'crypto' segment.
*
* @param verifier the signature verifier
*/
@Override
public void verifySignature(SignatureVerifier verifier) {
verifier.verify(signingInput(), crypto);
}
private byte[] signingInput() {
return concat(b64UrlEncode(header.bytes()), MyJwtHelper.PERIOD,
b64UrlEncode(content));
}
/**
* Allows retrieval of the full token.
*
* @return the encoded header, claims and crypto segments concatenated with "."
* characters
*/
@Override
public byte[] bytes() {
return concat(b64UrlEncode(header.bytes()), MyJwtHelper.PERIOD,
b64UrlEncode(content), MyJwtHelper.PERIOD, b64UrlEncode(crypto));
}
@Override
public String getClaims() {
return utf8Decode(content);
}
@Override
public String getEncoded() {
return utf8Decode(bytes());
}
public JwtHeader header() {
return this.header;
}
public byte[] getCrypto() {
return this.crypto;
}
@Override
public String toString() {
return header + " " + claims + " [" + crypto.length + " crypto bytes]";
}
}
向框架中注入一些需要用到的對象
@Configuration
public class TokenConfig {
// private static final String SIGNING_KEY = "uaa123";
@Resource
private UaaFeign uaaClient;
@Resource
private Environment environment;
/**
* MyJwtAccessTokenConverter類中用來解析原始用戶的JWT
*
* @return PublicKey
*/
@Bean
public PublicKey hifunPublicKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
String hifunPublicKey = environment.getProperty("jwt.publicKey");
System.out.println("***publicKeyStr:" + hifunPublicKey);
byte[] keyBytes = (new BASE64Decoder()).decodeBuffer(hifunPublicKey);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey rsaPublicKey = keyFactory.generatePublic(keySpec);
return rsaPublicKey;
}
/**
* 將Jwt作為令牌
*
* @return
*/
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
/**
* 配置Jwt令牌(秘鑰)
*
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
// JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
MyJwtAccessTokenConverter converter = new MyJwtAccessTokenConverter();
// converter.setSigningKey(SIGNING_KEY);
String publicKey = uaaClient.publicKey();
System.out.println("publicKey: " + publicKey);
converter.setVerifierKey(publicKey);
converter.setVerifier(new RsaVerifier(publicKey));
return converter;
}
}
配置框架中的配置TokenStore
@Configuration
public class ResourceServerConfig {
private static final String RESOURCE_ID = "res1";
@Resource
private TokenStore tokenStore;
@Resource
private MyAuthExceptionEntryPoint myAuthExceptionEntryPoint;
@Resource
private MyAccessDeniedHandler myAccessDeniedHandler;
@Resource
private MyTokenExtractor myTokenExtractor;
@Configuration
@EnableResourceServer
public class UserServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId(RESOURCE_ID)
.tokenStore(tokenStore)
.stateless(true)
.tokenExtractor(myTokenExtractor)
.authenticationEntryPoint(myAuthExceptionEntryPoint)
.accessDeniedHandler(myAccessDeniedHandler);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable().authorizeRequests()
// .antMatchers("/order/**").access("#oauth2.hasScope('ROLE_ADMIN')");
.antMatchers("/user/getTbUser**", "/user/getRoleCodes", "/user/getAuthorities","/uc/permission").permitAll()
.antMatchers("/user/**").hasAnyAuthority("hifun")/*access("#oauth2.hasScope('ROLE_USER')")*/
.antMatchers("/administrator/**").hasAnyAuthority("/users/");
}
}
}
feign
@FeignClient("TEST-UAA-CENTER")
public interface UaaClient {
@GetMapping(path = "/uaa/publicKey")
String publicKey();
}
4.2 分布式優(yōu)化
邏輯
- 授權(quán)碼模式中滔韵,session中緩存了上次請求的信息逻谦,在分布式中需要對session進(jìn)行持久化;
代碼
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置
spring:
redis:
host: 116.62.148.11
port: 6380
password:
timeout: 1000
jedis:
pool:
max-active: 8 # 連接池最大連接數(shù) (使用負(fù)值表示沒有限制)
max-idle: 8 # 連接池中的最大空閑連接
max-wait: -1s # 連接池最大阻塞等待時(shí)間(使用負(fù)值表示沒有限制)
min-idle: 0 # 連接池中的最小空閑連接
# session:
# store-type: redis
開啟session共享
添加@EnableRedisHttpSession注解即可陪蜻,會(huì)自動(dòng)將session中的數(shù)據(jù)存儲(chǔ)到redis中邦马。
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 600) //spring在多長時(shí)間后強(qiáng)制使redis中的session失效,默認(rèn)是1800.(單位/秒)
public class SessionConfig {}
Redis中的存儲(chǔ)結(jié)構(gòu)
4.3 資源服務(wù)器自定義異常返回
4.3.1 配置類配置
@Configuration
public class ResourceServerConfig {
private static final String RESOURCE_ID = "res1";
@Resource
private TokenStore tokenStore;
@Resource
private MyAuthExceptionEntryPoint myAuthExceptionEntryPoint;
@Resource
private MyAccessDeniedHandler myAccessDeniedHandler;
@Configuration
@EnableResourceServer
public class UserServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId(RESOURCE_ID)
.tokenStore(tokenStore)
.stateless(true)
.authenticationEntryPoint(myAuthExceptionEntryPoint)
.accessDeniedHandler(myAccessDeniedHandler);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// .antMatchers("/order/**").access("#oauth2.hasScope('ROLE_ADMIN')");
.antMatchers("/user/**").hasAuthority("hifun");
}
}
}
原理:
-
如果是不帶token訪問需要認(rèn)證的資源,會(huì)拋出AccessDeniedException異常宴卖,進(jìn)入ExceptionTranslationFilter過濾器進(jìn)行處理滋将,將AccessDeniedException異常InsufficientAuthenticationException異常,在sendStartAuthentication方法中調(diào)用容器中的自定義的MyAuthExceptionEntryPoint類的commence方法症昏,在該方法中自定義異常處理方式随闽。
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException { SecurityContextHolder.getContext().setAuthentication(null); requestCache.saveRequest(request, response); logger.debug("Calling Authentication entry point."); authenticationEntryPoint.commence(request, response, reason); }
如果是攜帶token且token無效或過期,會(huì)在OAuth2AuthenticationProcessingFilter過濾器對token進(jìn)行驗(yàn)證(decode方法)肝谭。如果token無效掘宪,在JwtAccessTokenConverter類中拋出InvalidTokenException異常;如果token過期攘烛,在DefaultTokenServices 的loadAuthentication方法中拋出InvalidTokenException異常魏滚。
如果是token權(quán)限不足,則會(huì)拋出AccessDeniedException異常進(jìn)入ExceptionTranslationFilter過濾器處理坟漱。并調(diào)用自定義的異常類鼠次。
4.3.2 自定義異常類
自定義401異常
@Component
public class MyAuthExceptionEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Throwable cause = authException.getCause();
response.setStatus(HttpStatus.OK.value());
response.setHeader("Content-Type", "application/json;charset=UTF-8");
CommonResult<String> result = null;
try {
if(cause instanceof InvalidTokenException) {
result = CommonResult.error(HttpStatus.UNAUTHORIZED.value(),"認(rèn)證失敗,無效或過期token");
}else{
result = CommonResult.error(HttpStatus.UNAUTHORIZED.value(),"認(rèn)證失敗,沒有攜帶token");
}
response.getWriter().write(new ObjectMapper().writeValueAsString(result));
} catch (IOException e) {
e.printStackTrace();
}
}
}
自定義403異常
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(HttpStatus.OK.value());
response.setHeader("Content-Type", "application/json;charset=UTF-8");
try {
CommonResult<String> result = CommonResult.error(HttpStatus.FORBIDDEN.value(),"權(quán)限不足");
response.getWriter().write(new ObjectMapper().writeValueAsString(result));
} catch (IOException e) {
e.printStackTrace();
}
}
}
4.4 給token失效用戶匿名權(quán)限
如果接口是沒有權(quán)限的,但是因?yàn)橛脩舻膖oken過期或失效原因?qū)е略L問失敗,這是不符合邏輯的腥寇,所以需要給這種用戶匿名權(quán)限成翩,使其可以正常訪問沒有權(quán)限的接口。
但是如何區(qū)分是401異常還是403異常呢赦役?如果報(bào)403但是request中沒有token麻敌,說明是未攜帶token;如果報(bào)403但是request中有token掂摔,則檢查token是否有效庸论,token有效則檢查token是否過期,如果token未過期說明是token沒有權(quán)限棒呛。
或
區(qū)分需要鑒權(quán)和無需鑒權(quán)的接口的路徑,如無需鑒權(quán)的借口使用 /xxx-anon/xxxx 域携,在網(wǎng)關(guān)路由時(shí)直接過濾掉token參數(shù)簇秒。
routes:
- id: SMARTCOOK
uri: lb://SMARTCOOK
filters:
- SetPath=/menu-anon/{path}
- RemoveResponseHeader=Mars-Token
- RemoveResponseHeader=Authorization
- RemoveRequestParameter=access_token
predicates:
- Path=/v1/api-menu/menu-anon/{path} #直接訪問接口
4.5 授權(quán)碼模式使用自定義的UI界面和路徑
注意:
如果出現(xiàn)錯(cuò)誤"User must be authenticated with Spring Security before authorization can be completed",是在AuthorizationEndpoint類(/oauth/authorize)中拋出的異常秀鞭,因?yàn)闆]有經(jīng)過用戶登錄直接跳轉(zhuǎn)到了/oauth/authorize接口趋观,導(dǎo)致principle參數(shù)為null。
原因是在安全配置中開放了/oauth/authorize接口的權(quán)限锋边。
首先設(shè)置用戶賬號密碼驗(yàn)證頁面/login
- 在resources/public目錄下創(chuàng)建登錄頁面的html文件皱坛,提交的接口設(shè)置為/uaa/login(接口地址隨意)
- 將創(chuàng)建的html登錄頁面和提交的接口地址設(shè)置到WebSecurityConfigurerAdapter繼承類的endpoint對象中
endpoint ... .formLogin().loginPage("/uaa/public/login1.html").loginProcessingUrl("/uaa/login");
表示說那個(gè)表單登錄,請求/oauth/authorize重定向的登錄頁面地址為/uaa/public/login.html豆巨,表單提交認(rèn)證接口為/uaa/login(與html中提交的接口一致)剩辟。 - 需要通過靜態(tài)資源映射將請求地址/uaa/public/login1.html映射到真實(shí)資源地址classpath:public/login1.html。
@Configuration
public class WebMvcConfigurerAdapter implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
// registry.addViewController("/").setViewName("login");
// registry.addViewController("/login.html").setViewName("login");
// registry.addViewController("/uaa/hxrlogin/pages").setViewName("");
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/uaa/hxrlogin/**").addResourceLocations("classpath:hxrlogin/");
// registry.addResourceHandler("/uaa/public/**").addResourceLocations("classpath:public/");
}
}
然后修改客戶端認(rèn)證接口/oauth/authorize
在@EnableAuthorizationServer注釋的繼承自AuthorizationServerConfigurerAdapter類的授權(quán)配置類中的endpoints對象中endpoint ... .pathMapping("/oauth/authorize","/uaa/oauth/authorize");
修改請求和刷新token的接口/oauth/token
在@EnableAuthorizationServer注釋的繼承自AuthorizationServerConfigurerAdapter類的授權(quán)配置類中的endpoints對象中endpoint ... .pathMapping("/oauth/token","/uaa/oauth/token");
再設(shè)置允許授權(quán)頁面/oauth/confirm_access
- 在上述endpoints對象后再跟上 .pathMapping("/oauth/confirm_access","/uaa/oauth/confirm_access");即可
將請求確認(rèn)授權(quán)頁面的默認(rèn)接口路徑/oauth/confirm_access改為/uaa/oauth/confirm_access往扔。 - 創(chuàng)建接口類贩猎,接口路徑為需要與配置類中的對應(yīng),即/uaa/oauth/confirm_access萍膛。因?yàn)橐呀?jīng)將/oauth/authorize的接口地址改為/uaa/oauth/authorize吭服,因此這里action提交的路徑先改為/uaa/oauth/authorize。
@RestController
@SessionAttributes("authorizationRequest")
public class BootGrantController {
private static final String ERROR = "<html><body><h1>OAuth Error</h1><p>%errorSummary%</p></body></html>";
@RequestMapping("/uaa/oauth/confirm_access")
public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
final String approvalContent = createTemplate(model, request);
if (request.getAttribute("_csrf") != null) {
model.put("_csrf", request.getAttribute("_csrf"));
}
View approvalView = new View() {
@Override
public String getContentType() {
return "text/html";
}
@Override
public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType(getContentType());
response.getWriter().append(approvalContent);
}
};
return new ModelAndView(approvalView, model);
}
protected String createTemplate(Map<String, Object> model, HttpServletRequest request) {
AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
String clientId = authorizationRequest.getClientId();
StringBuilder builder = new StringBuilder();
builder.append("<html><body><h1>OAuth Approval</h1>");
builder.append("<p>Do you authorize \"").append(HtmlUtils.htmlEscape(clientId));
builder.append("\" to access your protected resources?</p>");
builder.append("<form id=\"confirmationForm\" name=\"confirmationForm\" action=\"");
String requestPath = ServletUriComponentsBuilder.fromContextPath(request).build().getPath();
if (requestPath == null) {
requestPath = "";
}
builder.append(requestPath).append("/uaa/oauth/authorize\" method=\"post\">");
builder.append("<input name=\"user_oauth_approval\" value=\"true\" type=\"hidden\"/>");
String csrfTemplate = null;
CsrfToken csrfToken = (CsrfToken) (model.containsKey("_csrf") ? model.get("_csrf") : request.getAttribute("_csrf"));
if (csrfToken != null) {
csrfTemplate = "<input type=\"hidden\" name=\"" + HtmlUtils.htmlEscape(csrfToken.getParameterName()) +
"\" value=\"" + HtmlUtils.htmlEscape(csrfToken.getToken()) + "\" />";
}
if (csrfTemplate != null) {
builder.append(csrfTemplate);
}
String authorizeInputTemplate = "<label><input name=\"authorize\" value=\"Authorize\" type=\"submit\"/></label></form>";
if (model.containsKey("scopes") || request.getAttribute("scopes") != null) {
builder.append(createScopes(model, request));
builder.append(authorizeInputTemplate);
} else {
builder.append(authorizeInputTemplate);
builder.append("<form id=\"denialForm\" name=\"denialForm\" action=\"");
builder.append(requestPath).append("/uaa/oauth/authorize\" method=\"post\">");
builder.append("<input name=\"user_oauth_approval\" value=\"false\" type=\"hidden\"/>");
if (csrfTemplate != null) {
builder.append(csrfTemplate);
}
builder.append("<label><input name=\"deny\" value=\"Deny\" type=\"submit\"/></label></form>");
}
builder.append("</body></html>");
return builder.toString();
}
private CharSequence createScopes(Map<String, Object> model, HttpServletRequest request) {
StringBuilder builder = new StringBuilder("<ul>");
@SuppressWarnings("unchecked")
Map<String, String> scopes = (Map<String, String>) (model.containsKey("scopes") ?
model.get("scopes") : request.getAttribute("scopes"));
for (String scope : scopes.keySet()) {
String approved = "true".equals(scopes.get(scope)) ? " checked" : "";
String denied = !"true".equals(scopes.get(scope)) ? " checked" : "";
scope = HtmlUtils.htmlEscape(scope);
builder.append("<li><div class=\"form-group\">");
builder.append(scope).append(": <input type=\"radio\" name=\"");
builder.append(scope).append("\" value=\"true\"").append(approved).append(">Approve</input> ");
builder.append("<input type=\"radio\" name=\"").append(scope).append("\" value=\"false\"");
builder.append(denied).append(">Deny</input></div></li>");
}
builder.append("</ul>");
return builder.toString();
}
@RequestMapping("/uaa/oauth/error")
public ModelAndView handleError(HttpServletRequest request) {
Map<String, Object> model = new HashMap<String, Object>();
Object error = request.getAttribute("error");
// The error summary may contain malicious user input,
// it needs to be escaped to prevent XSS
String errorSummary;
if (error instanceof OAuth2Exception) {
OAuth2Exception oauthError = (OAuth2Exception) error;
errorSummary = HtmlUtils.htmlEscape(oauthError.getSummary());
} else {
errorSummary = "Unknown error";
}
final String errorContent = ERROR.replace("%errorSummary%", errorSummary);
View errorView = new View() {
@Override
public String getContentType() {
return "text/html";
}
@Override
public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType(getContentType());
response.getWriter().append(errorContent);
}
};
return new ModelAndView(errorView, model);
}
}
這里是直接將html代碼寫入到j(luò)ava代碼中蝗罗,應(yīng)該可以從外部文件讀入艇棕,這里暫時(shí)沒有做。
4.6 網(wǎng)關(guān)gateway的優(yōu)化
- 上述提到的串塑,轉(zhuǎn)發(fā)請求但不改變請求頭中的請求地址沼琉。
- 如果訪問/menu-anon等無權(quán)限的接口,則在gateway中將token去掉拟赊。以免token失效導(dǎo)致無法訪問無權(quán)限接口的問題出現(xiàn)刺桃。(最好的還是在oauth框架中,如果token過期或無效,則給匿名用戶權(quán)限)
- 對特定的接口地址進(jìn)行攔截瑟慈,只能通過feign調(diào)用(如通過用戶名查詢用戶的信息接口)
4.7 使用refresh_token刷新過期的token
- 生成token時(shí)桃移,將refresh_token存儲(chǔ)到redis中,key的過期時(shí)間和refresh_token相同葛碧。
- 如果驗(yàn)證token時(shí)借杰,發(fā)現(xiàn)token過期,則查詢r(jià)edis中是否有對應(yīng)的refresh_token进泼。
存在則用refresh_token生成新的token并設(shè)置到響應(yīng)中替換過期的token蔗衡,并將新的refresh_token存儲(chǔ)到redis中。
不存在則拋出token過期異常乳绕,前端頁面跳轉(zhuǎn)到登錄绞惦。
結(jié)論:無法實(shí)現(xiàn),因?yàn)樗⑿聇oken需要攜帶client的密碼洋措,而密碼無法從數(shù)據(jù)庫中查詢济蝉,只有客戶端知道。
4.8 令牌增強(qiáng)
如想創(chuàng)建的令牌中攜帶令牌的創(chuàng)建時(shí)間菠发,就需要對令牌的功能進(jìn)行增強(qiáng)王滤。
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
public class MyTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
final Map<String, Object> additionalInfo = new HashMap<>();
additionalInfo.put("cre", System.currentTimeMillis()/1000);
// 注意添加的額外信息,最好不要和已有的json對象中的key重名滓鸠,容易出現(xiàn)錯(cuò)誤
//additionalInfo.put("authorities", user.getAuthorities());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
return accessToken;
}
}
4.8 賬戶黑名單
情況一:
- 如果將賬號的enabled置為false雁乡,表示該賬號被禁用。
- 在刷新token時(shí)糜俗,框架會(huì)檢查enabled是否為true踱稍,否則拋異常。
問題是如果token有效時(shí)間為一小時(shí)悠抹,那么最長在這1小時(shí)內(nèi)被禁用的用戶依然可以訪問寞射,所以需要將該用戶拉入黑名單,黑名單有效期和token有效期一致锌钮。
情況二:
如果用戶token中的信息(如過期時(shí)間桥温、權(quán)限等)變化,原token就不可用梁丘,需要放入黑名單笤虫,讓用戶重新登錄毙沾。
情況三:
用戶主動(dòng)注銷,則將token放入黑名單,讓用戶重新登錄癣猾。
邏輯
- 當(dāng)用戶信息發(fā)生變化時(shí)师脂,需要將該用戶和當(dāng)前時(shí)間存入redis中的黑名單表乡数。
- 每次請求時(shí)晚唇,檢查黑名單中是否有該請求用戶,如果在黑名單中,且token的創(chuàng)建時(shí)間比黑名單中的時(shí)間要早酱塔,則攔截該請求沥邻。客戶端需要重新登錄并獲取token羊娃。
如果是刷新token請求唐全,也需要檢查refresh_token的用戶和創(chuàng)建時(shí)間,同上蕊玷。
代碼
首先在token中添加token的創(chuàng)建時(shí)間邮利。創(chuàng)建TokenEnhancer的實(shí)現(xiàn)類,實(shí)現(xiàn)enhance方法垃帅,在該方法中為accessToken添加信息延届。
@Component
public class MyTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
final Map<String, Object> additionalInfo = new HashMap<>();
additionalInfo.put("cre", System.currentTimeMillis()/1000);
// 注意添加的額外信息,最好不要和已有的json對象中的key重名贸诚,容易出現(xiàn)錯(cuò)誤
//additionalInfo.put("authorities", user.getAuthorities());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
return accessToken;
}
}
將自定義的類添加到配置中的token增強(qiáng)器鏈中
@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
......
@Resource
private MyTokenEnhancer myTokenEnhancer;
/**
* 令牌管理服務(wù)
*
* @return
*/
@Bean
public AuthorizationServerTokenServices tokenServices() {
DefaultTokenServices services = new DefaultTokenServices();
services.setClientDetailsService(clientDetailsService); //客戶端詳情服務(wù)
services.setSupportRefreshToken(true); //支持刷新令牌
services.setTokenStore(tokenStore); //令牌的存儲(chǔ)策略
//令牌增強(qiáng),設(shè)置JWT令牌
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(myTokenEnhancer,accessTokenConverter));
services.setTokenEnhancer(tokenEnhancerChain);
// services.setAccessTokenValiditySeconds(7200); //令牌默認(rèn)有效時(shí)間2小時(shí)
// services.setRefreshTokenValiditySeconds(259200); //刷新令牌默認(rèn)有效期3天
return services;
}
在gateway中設(shè)置黑名單過濾器祷愉。檢查access_token和refresh_token的用戶和過期時(shí)間。
@Component
public class ExpiredTokenFilter implements GlobalFilter, Ordered {
private Map<String, Long> expiredMap = Collections.emptyMap();
@Resource
private ExpiredTokenFeign expiredTokenFeign;
@Bean
@ConditionalOnMissingBean
public HttpMessageConverters messageConverters(ObjectProvider<HttpMessageConverter<?>> converters) {
return new HttpMessageConverters(converters.orderedStream().collect(Collectors.toList()));
}
/**
* 定時(shí)同步過期用戶
*/
@Scheduled(fixedDelay = 5000)
// @Scheduled(cron = "${cron.sync_expired_token}")
// @Scheduled(cron = "0 0/5 * * * ?")
public void syncBlackIPList() {
try {
expiredMap = expiredTokenFeign.getExpiredToken();
System.out.println("同步過期token:" + expiredMap);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 篩選已失效的token
*
* @param exchange
* @param chain
* @return
*/
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (expiredMap.isEmpty()) {
return chain.filter(exchange);
}
ServerHttpRequest request = exchange.getRequest();
String token = "";
//從請求頭中獲取token
HttpHeaders httpHeaders = request.getHeaders();
List<String> authorization = httpHeaders.get("Authorization");
if (!Objects.isNull(authorization)) {
for (String value : authorization) { // typically there is only one (most servers enforce that)
if ((value.toLowerCase().startsWith("Bearer".toLowerCase()))) {
token = value.substring("Bearer".length()).trim();
// Add this here for the auth details later. Would be better to change the signature of this method.
int commaIndex = token.indexOf(',');
if (commaIndex > 0) {
token = token.substring(0, commaIndex);
}
}
}
}
//從參數(shù)中獲取token
if (StringUtils.isEmpty(token)) {
MultiValueMap<String, String> queryParams = request.getQueryParams();
token = queryParams.getFirst("access_token");
if (StringUtils.isEmpty(queryParams.getFirst("grant_type"))) {
token = queryParams.getFirst("refresh_token");
}
}
if (!StringUtils.isEmpty(token)) {
int firstPeriod = token.indexOf('.');
int lastPeriod = token.lastIndexOf('.');
if (firstPeriod <= 0 || lastPeriod <= firstPeriod) {
throw new IllegalArgumentException("JWT must have 3 tokens");
}
CharBuffer buffer = CharBuffer.wrap(token, 0, firstPeriod);
buffer.limit(lastPeriod).position(firstPeriod + 1);
byte[] decode = Base64.decode(buffer);
String content = new String(decode);
JSONObject jsonObject = new JSONObject(content);
String userId = jsonObject.getStr("user_name");
Long createTimestamp = jsonObject.getLong("cre");
boolean isExpired = expiredMap.containsKey(userId) && expiredMap.get(userId).compareTo(createTimestamp) > 0;
// Assert.isTrue(!isExpired, "gateway: 令牌已失效赦颇,請重新登錄");
if (isExpired) {
ServerHttpResponse response = exchange.getResponse();
CommonResult<Object> error = CommonResult.error(HttpStatus.UNAUTHORIZED.value(), "gateway: token已失效,請重新登錄");
byte[] bytes = error.toString().getBytes();
DataBuffer wrap = response.bufferFactory().wrap(bytes);
response.setStatusCode(HttpStatus.OK);
return response.writeWith(Mono.just(wrap));
}
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
feign請求用戶中心獲取過期的token名單
@FeignClient(name = "SECURITY-USER")
public interface ExpiredTokenFeign {
@GetMapping(path = "/feign/user/getExpiredToken")
Map<String,Long> getExpiredToken();
}
在用戶中心赴涵,如果用戶的有效性媒怯、角色、權(quán)限髓窜、用戶角色綁定扇苞、角色權(quán)限綁定發(fā)生改變,需要查詢對應(yīng)的用戶id寄纵,通過異步方式發(fā)送到rabbitmq中添加到redis中的過期列表中鳖敷。
4.9 后臺(tái)如何控制可以修改的權(quán)限
用戶只能修改其擁有的角色,權(quán)限的自集合程拭。如何控制該用戶可以修改的權(quán)限定踱。
4.10 權(quán)限分配設(shè)計(jì)
用戶不分級,角色沒有分級恃鞋,權(quán)限有分級崖媚。
權(quán)限包括系統(tǒng)管理(用戶管理,權(quán)限管理恤浪,角色管理等)畅哑,各子服務(wù)的權(quán)限等。
- 用戶通過部門和崗位進(jìn)行分層水由,與權(quán)限無關(guān)荠呐。如果用戶有系統(tǒng)管理權(quán)限的用戶權(quán)限,那么可以根據(jù)權(quán)限大小對用戶的信息和權(quán)限進(jìn)行操作。
- 用戶如果有系統(tǒng)管理權(quán)限的角色權(quán)限泥张,那么可以創(chuàng)建角色呵恢,綁定自己擁有的權(quán)限』幔可以創(chuàng)建新用戶綁定該角色瑰剃。
- 單獨(dú)給開發(fā)人員開放一個(gè)權(quán)限管理系統(tǒng)用于權(quán)限的管理。
規(guī)劃:
- 用戶表進(jìn)行分層
- 再創(chuàng)建一個(gè)中間表用于關(guān)聯(lián)用戶表和角色表筝野,關(guān)聯(lián)用戶和其創(chuàng)建的角色晌姚。
- 如果用戶有系統(tǒng)管理權(quán)限中的用戶管理權(quán)限,可以對其子用戶進(jìn)行增刪改查歇竟,綁定該用戶創(chuàng)建的角色挥唠。如果用戶有角色管理權(quán)限,可以對其關(guān)聯(lián)的角色進(jìn)行增刪改查焕议,將其擁有的權(quán)限綁定到該角色宝磨。如果用戶有權(quán)限管理權(quán)限,可以對其子權(quán)限進(jìn)行盅安。 父級可以查看其所有子級的內(nèi)容唤锉。
4.11 刪除瀏覽器的緩存cookie
4.12 前端頁面定制,并在不跳轉(zhuǎn)的情況下顯示錯(cuò)誤信息
4.13 使用form表單提交的問題
訪問/oauth/authorize接口别瞭,框架中會(huì)自動(dòng)重定向到其他頁面窿祥。這回導(dǎo)致以下幾個(gè)問題
- 1.無法在原頁面上顯示錯(cuò)誤信息;
- 2.無法通過前端或者后臺(tái)刪除瀏覽器緩存的cookie蝙寨。
但是使用ajax請求會(huì)存在同源問題晒衩,導(dǎo)致跳轉(zhuǎn)失敗。所以最好的還是通過改寫/oauth/authorize的源碼墙歪,進(jìn)行正常的響應(yīng)听系,將重定向地址放在響應(yīng)體中讓前端自主跳轉(zhuǎn)。
4.14 異常捕獲
添加如下異常處理類虹菲,但是無法處理過濾器鏈中的異常靠胜。
import feign.FeignException;
import oauth2.entities.CommonResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ExceptionAdapter {
@ExceptionHandler({FeignException.class})
public CommonResult<String> feignException(FeignException feignException) {
String message = feignException.getMessage();
return CommonResult.error(message);
}
@ExceptionHandler({Exception.class})
public CommonResult<String> exception(Exception e) {
String message = e.getMessage();
e.printStackTrace();
return CommonResult.error(message);
}
}
五、附
5.1 JWT工具類
如果想要對JWT令牌進(jìn)行各種操作毕源,可以使用如下的工具類髓帽。
import io.jsonwebtoken.*;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.jwt.JwtHelper;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* @Author: CJ
* @Data: 2020/6/11 9:17
*/
@Data
@Component
public class JwtTokenUtil {
// @Value("${jwt.secret}")
// private String secret;
@Value("${jwt.expiration}")
private Long expiration;
@Value("${jwt.header}")
private String header;
@Resource
private RSAPrivateKey rsaPrivateKey;
@Resource
private RSAPublicKey rsaPublicKey;
// @Resource
// private KeyPairConfig keyPairConfig;
/**
* 生成jwt令牌
* @param userDetails
* @return
*/
public String generateToken(UserDetails userDetails) {
HashMap<String, Object> claims = new HashMap<>(2);
claims.put("sub", userDetails.getUsername());
claims.put("created", new Date());
return generateToken(claims);
}
/**
* 令牌的過期時(shí)間,加密算法和秘鑰
* @param claims
* @return
*/
private String generateToken(Map<String, Object> claims) {
Date date = new Date(System.currentTimeMillis() + expiration);
return Jwts.builder().setClaims(claims)
.setExpiration(date)
.signWith(rsaPrivateKey,SignatureAlgorithm.RS256)
// .signWith(SignatureAlgorithm.RS256,keyPairConfig.getPrivateKey())
.compact();
}
/**
* 獲取token中的用戶名
* @param token
* @return
*/
public String getUsernameFromToken(String token) {
String username = null;
try {
Claims claims = getClaimsFromToken(token);
// System.out.println("claims: " + claims);
username = (String)claims.get("user_name");
} catch (Exception e) {
// throw new IllegalArgumentException(e.getMessage());
System.out.println("解析JWT異常: " + e.getMessage());
}
return username;
}
/**
* 獲取token中的claims
* @param token
* @return
*/
public Claims getClaimsFromToken(String token) {
Claims claims = null;
try {
//獲取claims的過程就是對token合法性檢驗(yàn)的過程脑豹,將token解析為Claims對象
JwtParser jwtParser = Jwts.parser().setSigningKey(publicKey);
// JwtParser jwtParser = Jwts.parser().setSigningKey(keyPairConfig.getPublicKey());
Jws<Claims> claimsJws = jwtParser.parseClaimsJws(token);
claims = claimsJws.getBody();
} catch (ExpiredJwtException e) {
e.printStackTrace();
throw new IllegalArgumentException("getClaimsFromToken:" + e.getMessage());
}
return claims;
}
/**
* 判斷token是否過期
* @param token
* @return
*/
public Boolean isTokenExpired(String token) {
Claims claims = getClaimsFromToken(token);
Date expiration = claims.getExpiration();
return expiration.before(new Date());
}
/**
* 刷新token令牌郑藏,將新的生成時(shí)間放入claims覆蓋原時(shí)間并和從新生成token
* @param token
* @return
*/
public String refreshToken(String token) {
String refreshedToken = null;
try {
Claims claims = getClaimsFromToken(token);
claims.put("created", new Date());
refreshedToken = generateToken(claims);
} catch (Exception e) {
// throw new IllegalArgumentException(e.getMessage());
}
return refreshedToken;
}
/**
* 校驗(yàn)token是否合法和過期
* @param token
* @param userDetails
* @return
*/
public Boolean validateToken(String token, UserDetails userDetails) {
String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
5.2 IP解析工具類
import org.apache.commons.lang.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
public class IpUtils {
private static final String IP_ADDRESS_SEPARATOR = ".";
private IpUtils() {
throw new IllegalStateException("Utility class");
}
/**
* 將IP轉(zhuǎn)成十進(jìn)制整數(shù)
*
* @param strIp 肉眼可讀的ip
* @return 整數(shù)類型的ip
*/
public static int ip2Long(String strIp) {
if (StringUtils.isBlank(strIp)) {
return 0;
}
String regex = "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}";
if (!strIp.matches(regex)) {
// 非IPv4(基本上是IPv6),直接返回0
return 0;
}
long[] ip = new long[4];
// 先找到IP地址字符串中.的位置
int position1 = strIp.indexOf(IP_ADDRESS_SEPARATOR);
int position2 = strIp.indexOf(IP_ADDRESS_SEPARATOR, position1 + 1);
int position3 = strIp.indexOf(IP_ADDRESS_SEPARATOR, position2 + 1);
// 將每個(gè).之間的字符串轉(zhuǎn)換成整型
ip[0] = Long.parseLong(strIp.substring(0, position1));
ip[1] = Long.parseLong(strIp.substring(position1 + 1, position2));
ip[2] = Long.parseLong(strIp.substring(position2 + 1, position3));
ip[3] = Long.parseLong(strIp.substring(position3 + 1));
long longIp = (ip[0] << 24) + (ip[1] << 16) + (ip[2] << 8) + ip[3];
if (longIp > Integer.MAX_VALUE) {
// 把范圍控制在int內(nèi)瘩欺,這樣數(shù)據(jù)庫可以用int保存
longIp -= 4294967296L;
}
return (int) longIp;
}
/**
* 將十進(jìn)制整數(shù)形式轉(zhuǎn)換成IP地址
*
* @param longIp 整數(shù)類型的ip
* @return 肉眼可讀的ip
*/
public static String long2Ip(long longIp) {
StringBuilder ip = new StringBuilder();
for (int i = 3; i >= 0; i--) {
ip.insert(0, (longIp & 0xff));
if (i != 0) {
ip.insert(0, IP_ADDRESS_SEPARATOR);
}
longIp = longIp >> 8;
}
return ip.toString();
}
/**
* 獲取客戶端IP
*
* @return 獲取客戶端IP(僅在web訪問時(shí)有效)
*/
public static String getIp() {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) {
return "127.0.0.1";
}
HttpServletRequest request = requestAttributes.getRequest();
// 首先判斷Nginx里設(shè)置的真實(shí)IP必盖,需要依賴Nginx配置
String realIp = request.getHeader("x-real-ip");
if (realIp != null && !realIp.isEmpty()) {
return realIp;
}
String forwardedFor = request.getHeader("x-forwarded-for");
if (forwardedFor != null && !forwardedFor.isEmpty()) {
return forwardedFor.split(",")[0];
}
return request.getRemoteAddr();
}
/**
* 獲取整數(shù)類型的客戶端IP
*
* @return 整數(shù)類型的客戶端IP
*/
public static int getLongIp() {
return ip2Long(getIp());
}
}
5.3 固定放開的權(quán)限
固定的不需要鑒權(quán)的路徑可以寫在工具類中拌牲,可以調(diào)用方法自定義添加其他路徑,并返回全部的路徑歌粥。
public class PermitAllUrl {
private static final String[] ENDPOINTS = {"/actuator/health", "/actuator/env", "/actuator/metrics/**", "/actuator/trace", "/actuator/dump",
"/actuator/jolokia", "/actuator/info", "/actuator/logfile", "/actuator/refresh", "/actuator/flyway", "/actuator/liquibase",
"/actuator/heapdump", "/actuator/loggers", "/actuator/auditevents", "/actuator/env/PID", "/actuator/jolokia/**",
"/v2/api-docs/**", "/swagger-ui.html", "/swagger-resources/**", "/webjars/**"};
public static String[] permitAllUrl(String...urls) {
if (Objects.isNull(urls) && urls.length == 0) {
return ENDPOINTS;
}
HashSet<Object> set = new HashSet<>();
Collections.addAll(set, ENDPOINTS);
Collections.addAll(set, urls);
return set.toArray(new String[set.size()]);
}
}
六塌忽、源碼地址
許多都是直接修改源碼來實(shí)現(xiàn)擴(kuò)展功能,如果有更加優(yōu)雅的方式可以實(shí)現(xiàn)失驶,歡迎分享土居。
https://github.com/ChenJie666/security-uaa