Oauth2(下)

四脱柱、優(yōu)化

優(yōu)化點(diǎn)如下:

  1. 兼容性問題:
    • 因?yàn)榧纫嫒菰嫉顷懩K伐弹,又要兼容新建的管理員模塊,所以需要判斷是原始用戶還是管理員用戶榨为。
    • 驗(yàn)證token時(shí)需要判斷是認(rèn)證服務(wù)頒發(fā)的令牌還是老登陸系統(tǒng)頒發(fā)的令牌并分別驗(yàn)證惨好。
  2. 分布式問題:ExceptionTransactionFilter中將request請求信息保存到session中椅邓,如果是分布式部署,會(huì)有訪問不到session的問題發(fā)生昧狮。
  3. 異常返回問題:資源服務(wù)器自定義異常返回。

4.1 兼容性優(yōu)化

生成令牌

邏輯

  1. 音箱作為第三方需要通過授權(quán)碼模式獲取令牌板壮,攜帶令牌訪問資源(用戶信息在原始的登陸模塊中)逗鸣,校驗(yàn)賬號密碼時(shí)還需要分兩種情況如下
    • 密碼登陸
    • 驗(yàn)證碼登陸
  2. 管理員需要通過密碼模式獲取令牌(管理員信息在新建的內(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)令牌

邏輯

  1. 原始用戶模塊有自己的jwt_token纫普,通過該token從菜譜中獲取數(shù)據(jù)阅悍。因此需要通過火粉的公鑰驗(yàn)證該token是否有效,且給該用戶相應(yīng)的權(quán)限以訪問菜譜的接口昨稼。
  2. 管理員是標(biāo)準(zhǔn)的oauth_token节视,不需要大的改動(dòng)。

關(guān)鍵點(diǎn)

  1. 需要從請求中獲取火粉或管理員的token假栓。
  2. 需要修改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)化

邏輯

  1. 授權(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");
        }
    }
}

原理:

  1. 如果是不帶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);
        }
    
  2. 如果是攜帶token且token無效或過期,會(huì)在OAuth2AuthenticationProcessingFilter過濾器對token進(jìn)行驗(yàn)證(decode方法)肝谭。如果token無效掘宪,在JwtAccessTokenConverter類中拋出InvalidTokenException異常;如果token過期攘烛,在DefaultTokenServices 的loadAuthentication方法中拋出InvalidTokenException異常魏滚。

  3. 如果是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

  1. 在resources/public目錄下創(chuàng)建登錄頁面的html文件皱坛,提交的接口設(shè)置為/uaa/login(接口地址隨意)
  2. 將創(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中提交的接口一致)剩辟。
  3. 需要通過靜態(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
  1. 在上述endpoints對象后再跟上 .pathMapping("/oauth/confirm_access","/uaa/oauth/confirm_access");即可
    將請求確認(rèn)授權(quán)頁面的默認(rèn)接口路徑/oauth/confirm_access改為/uaa/oauth/confirm_access往扔。
  2. 創(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)化

  1. 上述提到的串塑,轉(zhuǎn)發(fā)請求但不改變請求頭中的請求地址沼琉。
  2. 如果訪問/menu-anon等無權(quán)限的接口,則在gateway中將token去掉拟赊。以免token失效導(dǎo)致無法訪問無權(quán)限接口的問題出現(xiàn)刺桃。(最好的還是在oauth框架中,如果token過期或無效,則給匿名用戶權(quán)限)
  3. 對特定的接口地址進(jìn)行攔截瑟慈,只能通過feign調(diào)用(如通過用戶名查詢用戶的信息接口)

4.7 使用refresh_token刷新過期的token

  1. 生成token時(shí)桃移,將refresh_token存儲(chǔ)到redis中,key的過期時(shí)間和refresh_token相同葛碧。
  2. 如果驗(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 賬戶黑名單

情況一:

  1. 如果將賬號的enabled置為false雁乡,表示該賬號被禁用。
  2. 在刷新token時(shí)糜俗,框架會(huì)檢查enabled是否為true踱稍,否則拋異常。
    問題是如果token有效時(shí)間為一小時(shí)悠抹,那么最長在這1小時(shí)內(nèi)被禁用的用戶依然可以訪問寞射,所以需要將該用戶拉入黑名單,黑名單有效期和token有效期一致锌钮。

情況二:
如果用戶token中的信息(如過期時(shí)間桥温、權(quán)限等)變化,原token就不可用梁丘,需要放入黑名單笤虫,讓用戶重新登錄毙沾。

情況三:
用戶主動(dòng)注銷,則將token放入黑名單,讓用戶重新登錄癣猾。

邏輯

  1. 當(dāng)用戶信息發(fā)生變化時(shí)师脂,需要將該用戶和當(dāng)前時(shí)間存入redis中的黑名單表乡数。
  2. 每次請求時(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)限等。

  1. 用戶通過部門和崗位進(jìn)行分層水由,與權(quán)限無關(guān)荠呐。如果用戶有系統(tǒng)管理權(quán)限的用戶權(quán)限,那么可以根據(jù)權(quán)限大小對用戶的信息和權(quán)限進(jìn)行操作。
  2. 用戶如果有系統(tǒng)管理權(quán)限的角色權(quán)限泥张,那么可以創(chuàng)建角色呵恢,綁定自己擁有的權(quán)限』幔可以創(chuàng)建新用戶綁定該角色瑰剃。
  3. 單獨(dú)給開發(fā)人員開放一個(gè)權(quán)限管理系統(tǒng)用于權(quán)限的管理。

規(guī)劃:

  1. 用戶表進(jìn)行分層
  2. 再創(chuàng)建一個(gè)中間表用于關(guān)聯(lián)用戶表和角色表筝野,關(guān)聯(lián)用戶和其創(chuàng)建的角色晌姚。
  3. 如果用戶有系統(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

https://github.com/ChenJie666/security-user

https://github.com/ChenJie666/security-gateway

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請通過簡信或評論聯(lián)系作者嬉探。
  • 序言:七十年代末擦耀,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子涩堤,更是在濱河造成了極大的恐慌眷蜓,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,839評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件胎围,死亡現(xiàn)場離奇詭異吁系,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)白魂,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,543評論 2 382
  • 文/潘曉璐 我一進(jìn)店門汽纤,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人福荸,你說我怎么就攤上這事蕴坪。” “怎么了逞姿?”我有些...
    開封第一講書人閱讀 153,116評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長捆等。 經(jīng)常有香客問我滞造,道長,這世上最難降的妖魔是什么栋烤? 我笑而不...
    開封第一講書人閱讀 55,371評論 1 279
  • 正文 為了忘掉前任谒养,我火速辦了婚禮,結(jié)果婚禮上明郭,老公的妹妹穿的比我還像新娘买窟。我一直安慰自己,他們只是感情好薯定,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,384評論 5 374
  • 文/花漫 我一把揭開白布始绍。 她就那樣靜靜地躺著,像睡著了一般话侄。 火紅的嫁衣襯著肌膚如雪亏推。 梳的紋絲不亂的頭發(fā)上学赛,一...
    開封第一講書人閱讀 49,111評論 1 285
  • 那天,我揣著相機(jī)與錄音吞杭,去河邊找鬼盏浇。 笑死,一個(gè)胖子當(dāng)著我的面吹牛芽狗,可吹牛的內(nèi)容都是我干的绢掰。 我是一名探鬼主播,決...
    沈念sama閱讀 38,416評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼童擎,長吁一口氣:“原來是場噩夢啊……” “哼滴劲!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起柔昼,我...
    開封第一講書人閱讀 37,053評論 0 259
  • 序言:老撾萬榮一對情侶失蹤哑芹,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后捕透,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體聪姿,經(jīng)...
    沈念sama閱讀 43,558評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,007評論 2 325
  • 正文 我和宋清朗相戀三年乙嘀,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了末购。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,117評論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡虎谢,死狀恐怖盟榴,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情婴噩,我是刑警寧澤擎场,帶...
    沈念sama閱讀 33,756評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站几莽,受9級特大地震影響迅办,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜章蚣,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,324評論 3 307
  • 文/蒙蒙 一站欺、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧纤垂,春花似錦矾策、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,315評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至吼鱼,卻和暖如春榄鉴,著一層夾襖步出監(jiān)牢的瞬間履磨,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,539評論 1 262
  • 我被黑心中介騙來泰國打工庆尘, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留剃诅,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,578評論 2 355
  • 正文 我出身青樓驶忌,卻偏偏與公主長得像矛辕,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子付魔,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,877評論 2 345

推薦閱讀更多精彩內(nèi)容