是什么?
Json web token (JWT), 是為了在網絡應用環境間傳遞聲明而執行的一種基於JSON的開放標准((RFC 7519).該token被設計為緊湊且安全的,特別適用於分布式站點的單點登錄(SSO)場景。JWT的聲明一般被用來在身份提供者和服務提供者間傳遞被認證的用戶身份信息,以便於從資源服務器獲取資源,也可以增加一些額外的其它業務邏輯所必須的聲明信息,該token也可直接被用於認證,也可被加密。
jwt是一套完整的簽名驗證機制,可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公用/專用密鑰對對JWT進行簽名。
什么時候應該使用JSON Web令牌?
以下是JSON Web令牌有用的一些情況:
- 授權:這是使用JWT的最常見方案。一旦用戶登錄,每個后續請求將包括JWT,從而允許用戶訪問該令牌允許的路由,服務和資源。單一登錄是當今廣泛使用JWT的一項功能,因為它的開銷很小並且可以在不同的域中輕松使用。
- 信息交換:JSON Web令牌是在各方之間安全地傳輸信息的好方法。因為可以對JWT進行簽名(例如,使用公鑰/私鑰對),所以您可以確定發件人是他們所說的人。此外,由於簽名是使用標頭和有效負載計算的,因此您還可以驗證內容是否遭到篡改。
JSON Web令牌結構是什么?
JSON Web令牌以緊湊的形式由三部分組成,這些部分由點(.
)分隔,分別是:
- 標頭
- 有效載荷
- 簽名
因此,JWT通常如下所示。
xxxxx.yyyyy.zzzzz
讓我們分解不同的部分。
標頭
標頭通常由兩部分組成:令牌的類型(即JWT)和所使用的簽名算法,例如HMAC SHA256或RSA。
例如:
{
"alg": "HS256",
"typ": "JWT"
}
然后,此JSON被Base64Url編碼以形成JWT的第一部分。
有效載荷
令牌的第二部分是有效負載,其中包含聲明。聲明是有關實體(通常是用戶)和其他數據的聲明。聲明有以下三種類型:
-
標准中注冊的聲明:這些是一組非強制性的但建議使用的預定義權利要求,以提供一組有用的,可互操作的權利要求。其中一些是: iss(發布者), exp(到期時間), sub(主題), aud(受眾群體)等。
請注意,聲明名稱僅是三個字符,因為JWT是緊湊的。
-
公共的聲明:使用JWT的人員可以隨意定義這些聲明。但是為避免沖突,應在 IANA JSON Web令牌注冊表中定義它們,或將其定義為包含抗沖突名稱空間的URI。
-
私有的聲明:這些都是使用它們同意並既不是當事人之間建立共享信息的自定義聲明注冊或公眾的權利要求。
有效負載示例可能是:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然后,對有效負載進行Base64Url編碼,以形成JSON Web令牌的第二部分。
請注意,對於已簽名的令牌,此信息盡管可以防止篡改,但任何人都可以讀取。除非將其加密,否則請勿將機密信息放入JWT的有效負載或報頭元素中。
簽名
要創建簽名部分,您必須獲取編碼的標頭,編碼的有效載荷,機密,標頭中指定的算法,並對其進行簽名。
例如,如果要使用HMAC SHA256算法,則將通過以下方式創建簽名:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
簽名用於驗證消息在此過程中沒有更改,並且對於使用私鑰進行簽名的令牌,它還可以驗證JWT的發送者是它所說的真實身份。
放在一起
輸出是三個由點分隔的Base64-URL字符串,可以在HTML和HTTP環境中輕松傳遞這些字符串,與基於XML的標准(例如SAML)相比,它更緊湊。
下面顯示了一個JWT,它已對先前的標頭和有效負載進行了編碼,並用一個秘密進行簽名。
如果您想使用JWT並將這些概念付諸實踐,則可以使用jwt.io Debugger解碼,驗證和生成JWT。
簡單示例
是不是看到這里還是不知所雲,其實我當時也是這么覺得的,后來通過實踐,才慢慢理解,所以這里我們也廢話少說,show me code。
創建springboot項目
本次示例采用的技術架構為:springboot + redis + shiro,頁面模板引擎用的是thymeleaf,首先我們創建個spring boot項目,為啥用spring boot,因為構建項目快啊,簡單方便。然后添加如下依賴(完整的直接去githu,會放鏈接):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.7.0</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.3.2</version>
</dependency>
因為我們這里用不到數據庫,所以就不配置數據庫相關信息,但如果你不配置數據庫,springboot啟動會報錯,為了讓springboot啟動不報錯,我們要在springboot啟動入口添加如下注解:
@SpringBootApplication(exclude={DataSourceAutoConfiguration.class,HibernateJpaAutoConfiguration.class})
然后就可以啟動了,如果沒有什么問題,繼續往下看
編寫jwt工具類
如果你在此之前連jwt是什么也不清楚,那也沒關系,先參照下面的代碼寫,我下面會解釋。我當時第一次接觸jwt的時候也是這么過來的,別害怕,就是干,別問誰給我的勇氣,反正不是梁靜茹😂
public class JwtUtil {
private static Logger logger = LoggerFactory.getLogger(JwtUtil.class);
private JwtUtil() {}
/**
* Description: 生成一個jwt字符串
*
* @param username 用戶名
* @param timeOut 超時時間(單位ms)
* @return java.lang.String
* @author syske
* @date 2019/3/4 17:26
*/
public static String encode(String username, String secret, long timeOut) {
Algorithm algorithm = Algorithm.HMAC256(secret);
String token = JWT.create()
//設置過期時間為一個小時
.withExpiresAt(new Date(System.currentTimeMillis() + timeOut))
//設置負載
.withClaim("username", username)
.sign(algorithm);
return token;
}
/**
* Description: 解密jwt
*
* @param token token
* @param secret secret
* @return java.util.Map<java.lang.String, com.auth0.jwt.interfaces.Claim>
* @author syske
* @date 2019/3/4 18:14
*/
public static Map<String, Claim> decode(String token, String secret) {
if (StringUtils.isEmpty(token) || StringUtils.isEmpty(secret)) {
logger.info("token:" + token + " , secret:" + secret);
throw new AuthorizationException("用戶狀態校驗失敗:會話已過期,請重新登陸");
}
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier jwtVerifier = JWT.require(algorithm).build();
DecodedJWT decodedJWT = null;
try {
decodedJWT = jwtVerifier.verify(token);
} catch (TokenExpiredException e) {
logger.error("會話已過期,請重新登陸:", e);
throw new AuthorizationException("會話已過期,請重新登陸");
}
return decodedJWT.getClaims();
}
/**
* 獲得token中的信息無需secret解密也能獲得
*
* @return token中包含的用戶名
*/
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
logger.error("獲取用戶名信息失敗:", e);
throw new AuthorizationException("獲取用戶名信息失敗");
}
}
}
上面的方法都很簡單,下來我們一個一個詳細講解:
-
第一個方法(encode)就是生成token,上面我們在荷載里面加入了用戶名,你也可以增加自己的其他用戶信息,但是不要放用戶密碼,因為荷載區任何人都可以解密,需要注意的是這個方法需要傳一個字符串(secret),這個字符串也可以叫做密鑰,你可以隨意指定,但為了安全考慮,一般會統計將固定字符串,拼接上用戶名,時間戳,然后加密(越復雜越好),這樣也比較安全,當然你的secret要存好,不然你沒法解密了😂,這也是我們用redis的原因,就是為了存用戶的secret,鍵名用token加你自己特殊處理過的字符串(當然也是越復雜越好,前提是你能正確拿到,並正常校驗)。
-
第二個方法(decode)是驗證token,驗證token是否合法,是否過期,在生成token的時候我們設置了一個過期時間,這個時間很重要,如果過期校驗也是通不過的;這里就要用到我們前面生成token的密鑰,沒有密鑰或者密鑰不正確,解密肯定不通過。
-
第三個方法是從荷載區中拿到我們放進去的信息,我獲取的是用戶名,如果你生成的時候放了其他數據,你也可以獲取其他數據。
其實,到這里jwt的知識點就完了,因為jwt提供的就是一套加密驗證的機制,下來我們要解決的token的傳輸、secret的存儲等問題和jwt沒什么關系,這些問題取決於你的各種解決方案。我們先說下會有哪些問題:
-
token傳輸:客戶端請求的時候,每次請求接口的時候都必須攜帶token,如果是所有的接口、頁面都是一個服務或者部署在同一個容器中,那沒什么問題,但如果登陸的服務(單點登錄)、接口服務、前端服務(前后端分離)都不是同一個服務器,你就需要解決token跨域傳輸。
-
token存儲:token登陸成功后是返回給客戶端的,客戶端如何存儲token,是放在cookie、sessionStoreage,還是localStorage,如果登陸和當前服務有跨域問題的話,你還需要考慮如何跨域共享token。
-
secret存儲問題:這個是服務端要考慮的問題,本次示例是存在redis中,當然你如果有其他更好的解決方案,歡迎分享。
上面這些問題,我會在后續詳細講解我的解決方案,這里就不過多贅述了,下來看看其他核心代碼。
jwt服務類
@Service
public class JwtService {
private static Logger logger = LoggerFactory.getLogger(JwtService.class);
// 過期時間30分鍾
public static final long EXPIRE_TIME = 30 * 60 * 1000;
@Autowired
private RedisUtil redisUtil;
/**
* Description:登錄獲取token
*
* @param user user
* @return java.lang.String
* @author sysker
* @date 2019/3/4 18:45
*/
public String login(User user) {
//進行登錄校驗
try {
if (user.getUsername().equalsIgnoreCase(user.getPassword())) {
return this.generateNewJwt(user.getUsername());
} else {
logger.info("賬號密碼錯誤:{}{}", user.getUsername(), user.getPassword());
throw new AuthorizationException("賬號密碼錯誤");
}
} catch (Exception e) {
logger.info("賬號密碼錯誤:{},{}", user.getUsername(), user.getPassword());
throw new AuthorizationException(e, "賬號密碼錯誤");
}
}
/**
* 過期時間小於半小時,返回新的jwt,否則返回原jwt
* @param jwt
* @return
*/
public String refreshJwt(String jwt) {
String secret = (String)redisUtil.get(jwt);
Map<String, Claim> map = JwtUtil.decode(jwt, secret);
if(map.get("exp").asLong()*1000 - System.currentTimeMillis()/1000<30*60*1000){
return this.generateNewJwt(map.get("name").asString());
}else{
return jwt;
}
}
/**
* Description: 生成新的jwt,並放入jwtMap中
*
* @return java.lang.String
* @author sysker
* date 2019/3/5 10:44
*/
private String generateNewJwt(String username) {
String data = SecretConstant.DATAKEY + username + UUIDUtil.getUUIDStr();
logger.debug("創建密鑰加密前data:", data);
String secretEncrypted = null;
try {
secretEncrypted = Base64Util.encryptBase64(AESSecretUtil.encryptToStr(data,
SecretConstant.BASE64SECRET));
} catch (Exception e) {
logger.error("base64加密失敗:", e);
throw new AuthorizationException("未知錯誤");
}
logger.debug("密鑰加密后data:", secretEncrypted);
String token = JwtUtil.encode(username, secretEncrypted, EXPIRE_TIME);
logger.debug("創建token:", token);
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, secretEncrypted, EXPIRE_TIME);
return token;
}
/**
* Description:檢查jwt有效性
*
* @return Boolean
* @author sysker
* @date 2019/3/4 18:47
*/
public ReturnEntity checkJwt(String jwt) {
String secret = (String)redisUtil.get(CommonConstant.PREFIX_USER_TOKEN + jwt);
JwtUtil.decode(jwt, secret);
return ReturnEntity.successResult(1, true);
}
/**
* Description: 作廢token,使該jwt失效
*
* @author sysker
* @date 2019/3/4 19:58
*/
public void inValid(String jwt) {
redisUtil.del(jwt);
}
上面用到的所有工具類,github示例中都有,服務中的這些方法基本都是調用jwt工具類中的方法,只是部分方法需要從redis中獲取用戶的secret,或者刪除、更新用戶的token。
其他實現思路
登陸攔截
登陸校驗我是在攔截器中實現的,但是這里需要解決的問題是攔截器中的異常處理問題,如果直接拋出異常,前台收到的是500異常,因為filter早於spring,所以集中異常處理並不能捕獲,查了很多資料后,最后增加一個異常過濾器,將異常轉發至自己的controller,然后在controller中拋出,然后在自己寫的TokenErrorController中集中處理:
jwt攔截器
public class JwtFilter extends BasicHttpAuthenticationFilter {
private final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
private AntPathMatcher antPathMatcher = new AntPathMatcher();
/**
* 執行登錄認證(判斷請求頭是否帶上token)
*
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
logger.info("JwtFilter-->>>isAccessAllowed-Method:init()");
//如果請求頭不存在token,則可能是執行登陸操作或是游客狀態訪問,直接返回true
if (isLoginAttempt(request, response)) {
return true;
}
//如果存在,則進入executeLogin方法執行登入,檢查token 是否正確
executeLogin(request, response);
return true;
}
/**
* 判斷用戶是否是登入,檢測headers里是否包含token字段
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
logger.info("JwtFilter-->>>isLoginAttempt-Method:init()");
HttpServletRequest req = (HttpServletRequest) request;
if (antPathMatcher.match("/userLogin", req.getRequestURI())) {
return true;
}
String token = req.getHeader(CommonConstant.ACCESS_TOKEN);
if (StringUtils.isEmpty(token)) {
return false;
}
JwtService jwtService = (JwtService) SpringContextUtil.getBean("jwtService");
Boolean isPass = (jwtService.checkJwt(token).getCode() == 1);
if (!isPass) {
return false;
}
logger.info("JwtFilter-->>>isLoginAttempt-Method:返回true");
return true;
}
/**
* 重寫AuthenticatingFilter的executeLogin方法丶執行登陸操作
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) {
logger.info("JwtFilter-->>>executeLogin-Method:init()");
String token = getTokenFromRequest((HttpServletRequest) request);
if (StringUtils.isEmpty(token)) {
throw new AuthorizationException("未找到用戶token令牌信息,請登陸");
}
JwtToken jwtToken = new JwtToken(token);
// 提交給realm進行登入,如果錯誤他會拋出異常並被捕獲, 反之則代表登入成功,返回true
getSubject(request, response).login(jwtToken);
return true;
}
/**
* 從request頭中獲取token
*
* @param request
* @return
*/
private String getTokenFromRequest(HttpServletRequest request) {
HttpServletRequest httpServletRequest = request;
return httpServletRequest.getHeader(CommonConstant.ACCESS_TOKEN);
}
/**
* 對跨域提供支持
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
logger.info("JwtFilter-->>>preHandle-Method:init()");
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域時會首先發送一個option請求,這里我們給option請求直接返回正常狀態
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
因為我用了shiro,所以我把jwtFilter配置在shiro里面了,當然你要可以單獨配置。對於單點登陸而言,目前我覺得shiro沒發揮他該有的作用。下面貼出shiro配置類:
@Configuration
public class ShiroConfig {
@Bean("securityManager")
public DefaultWebSecurityManager getManager(JwtShiroRealm jwtShiroRealm) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
// 使用自己的realm
manager.setRealm(jwtShiroRealm);
/*
* 關閉shiro自帶的session,詳情見文檔
* http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
manager.setSubjectDAO(subjectDAO);
return manager;
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
factoryBean.setSecurityManager(securityManager); //設置安全管理器
factoryBean.setLoginUrl("/userLogin");// 如果不設置默認會自動尋找Web工程根目錄下的"/login.jsp"頁面
factoryBean.setSuccessUrl("/index"); // 登錄成功后要跳轉的鏈接
factoryBean.setUnauthorizedUrl("/401");//未授權界面;
// 添加自己的過濾器並且取名為jwt
Map<String, Filter> filterMap = new HashMap<>();
filterMap.put("jwt", new JwtFilter());
factoryBean.setFilters(filterMap);
/*
* 自定義url規則
* http://shiro.apache.org/web.html#urls-
*/
Map<String, String> filterRuleMap = new LinkedHashMap<>();
// 訪問401和404頁面不通過我們的Filter
filterRuleMap.put("/401", "anon");
filterRuleMap.put("/static/**", "anon");
filterRuleMap.put("/css/**","anon");
filterRuleMap.put("/layui/**","anon");
filterRuleMap.put("/img/**","anon");
filterRuleMap.put("/js/**","anon");
filterRuleMap.put("/index","anon");
filterRuleMap.put("/login","anon");
filterRuleMap.put("/","anon");
filterRuleMap.put("/userLogin","anon");
filterRuleMap.put("/logout","logout");//配置退出 過濾器,其中的具體的退出代碼Shiro已經實現
filterRuleMap.put("/**","authc");//過濾鏈定義,從上向下順序執行,一般將/**放在最為下邊
// 所有請求通過我們自己的JWT Filter
filterRuleMap.put("/**", "jwt");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
/**
* 下面的代碼是添加注解支持
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 強制使用cglib,防止重復代理和可能引起代理出錯的問題
// https://zhuanlan.zhihu.com/p/29161098
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
異常攔截器:
@Component
public class ExceptionFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
chain.doFilter(request, response);
} catch (Exception e) {
Throwable eCause = e.getCause();
// 異常捕獲,發送到error controller
request.setAttribute("filter.error", eCause);
//將異常分發到/error/exthrow控制器
request.getRequestDispatcher("/error/exthrow").forward(request, response);
}
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
}
這里要注意的是,setAttribute("filter.error", e)
的時候,為了能區分是哪種異常,這里要區分判斷異常的類型,如果不處理的話,默認是javax.servlet.ServletException
異常,我是通過e.getCause()
來獲取引起異常的類型,也就是我在攔截器中拋出的異常。
異常攔截器配置:
@Configuration
public class WebFilterConfig {
@Bean
public FilterRegistrationBean exceptionFilterRegistration() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new ExceptionFilter());
registration.setName("exceptionFilter");
//此處盡量小,要比其他Filter靠前
registration.setOrder(-1);
return registration;
}
}
異常controller:
@Controller
public class ExceptionController {
@RequestMapping("/error/exthrow")
public void rethrow(HttpServletRequest request) throws Exception {
throw ((Exception) request.getAttribute("filter.error"));
}
}
controller集中異常處理,但一直沒起作用,就算我把錯誤轉發到controller,最后也沒進到這里,這個問題有時間了再研究下:
@RestController
public class TokenErrorController extends BasicErrorController {
public TokenErrorController(ErrorAttributes errorAttributes) {
super(errorAttributes, new ErrorProperties());
}
@Override
@RequestMapping(produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
return new ResponseEntity<>(ReturnEntity.failedResultMap(1, "未知錯誤"), HttpStatus.OK);
}
}
還寫了個ExceptionHandle,但是沒有攔截器之前不起作用,增加了異常攔截器以后就正常了:
@RestControllerAdvice
public class ExceptionHandle extends ResponseEntityExceptionHandler {
private Logger logger = LoggerFactory.getLogger(ExceptionHandle.class);
@ExceptionHandler(Exception.class)
public ReturnEntity handleException(Exception e) {
logger.warn("錯誤信息:", e);
if (e instanceof AuthorizationException) {
return ReturnEntity.failedResult(1, "未知錯誤");
} else {
return ReturnEntity.failedResult(2, "未知錯誤");
}
}
}
其實還有很多內容需要分享,但是由於篇幅問題,今天就到這里吧,最后提一點,因為filter早於spring,所以spring管理的組件,你在filter你是拿不到的,所以你要通過springContextUtil來獲取組件的實例:
public class SpringContextUtil {
private static ApplicationContext applicationContext;
//獲取上下文
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
//設置上下文
public static void setApplicationContext(ApplicationContext applicationContext) {
SpringContextUtil.applicationContext = applicationContext;
}
//通過名字獲取上下文中的bean
public static Object getBean(String name){
return applicationContext.getBean(name);
}
//通過類型獲取上下文中的bean
public static Object getBean(Class<?> requiredType){
return applicationContext.getBean(requiredType);
}
}
由於篇幅問題,這里就不貼出太多代碼了,想詳細了解的小伙伴直接去github查看完整示例:https://github.com/Syske/learning-dome-code/tree/master/springboot-jwt-demo