1. SpringBoot + Mybatis核心框架
2. PageHelper插件 + 通用Mapper插件
3. Shiro + Java-JWT無狀態鑑權認證機制
4. Redis(Jedis)緩存框架html
5. PostgreSql前端
徹底使用了 Shiro 的註解配置,保持高度的靈活性。java
放棄 Cookie ,Session ,使用JWT進行鑑權,徹底實現無狀態鑑權。mysql
JWT 密鑰支持過時時間。web
對跨域提供支持。redis
因爲開始是按照mysql方言寫的因此建立表時遇到些坑,算法
1.在postgre裏user、password是關鍵字須要加冒號,sql
2.Int自增應該寫成serial類型: 數據庫
先建立序列,而後設置字段的自增apache
CREATE SEQUENCE users_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
alter table users alter column id set default nextval('users_id_seq');
3.關於外鍵要直接寫在外鍵後面 role_id int not NULL references role (id)
4.配置文件以下(用的Druid鏈接池)
5.在作小demo期間還學了Mybatis Generator逆向生成代碼:
很好用的偷懶神器,先配置src\main\resources\generator\generatorConfig.xml文件,在項目根目錄下(前提是配置了mvn)在IDEA的Maven窗口Plugins中雙擊執行),可自動生成Model、Mapper、MapperXML。
首先Post用戶名與密碼到user/login進行登入,若是成功返回一個加密的AccessToken,失敗的話直接返回401錯誤(賬號或密碼不正確),之後訪問都帶上這個AccessToken便可,鑑權流程主要是重寫了Shiro的入口過濾器JWTFilter(BasicHttpAuthenticationFilter),判斷請求
Header裏面是否包含Authorization字段,有就進行Shiro的Token登陸認證受權(用戶訪問每個須要權限的請求必須在Header中添加Authorization字段存放AccessToken),沒有就以遊客直接訪問(有權限管控的話,以遊客訪問就會被攔截)
主要學習的幾個概念:
微服務集羣中的每一個服務,對外提供的都使用RESTful風格的接口。而RESTful風格的一個最重要的規範就是:服務的無狀態性,即
服務端不保存任何客戶端請求者信息
客戶端的每次請求必須具有自描述信息,經過這些信息識別客戶端身份
客戶端請求不依賴服務端的信息,屢次請求不須要必須訪問到同一臺服務器
服務端的集羣和狀態對客戶端透明
服務端能夠任意的遷移和伸縮(能夠方便的進行集羣化部署)
減少服務端存儲壓力
JSON Web Token(JWT)是目前最流行的跨域身份驗證解決方案
客戶端接收服務器返回的JWT,將其存儲在Cookie中。
此後,客戶端將在與服務器交互中都會帶JWT。若是將它存儲在Cookie中,就能夠自動發送,可是不會跨域,所以通常是將它放入HTTP請求的Header Authorization字段中。
Authorization: Bearer
當跨域時,也能夠將JWT被放置於POST請求的數據主體中。
JWT頭部分是一個描述JWT元數據的JSON對象
有效載荷部分,是JWT的主體內容部分,也是一個JSON對象,包含須要傳遞的數據。
簽名哈希部分是對上面兩部分數據簽名,經過指定的算法生成哈希,以確保數據不會被篡改。
在網上搜了些關於加密的算法,通常採用MD5+鹽的算法,可是當兩個用戶的明文密碼相同時進行加密,會發現數據庫中存在相同結構的暗文密碼,因此採用AES-128 + Base64是以賬號+密碼的形式進行加密密碼,由於賬號具備惟一性,因此也不會出現相同結構的暗文密碼這個問題
寫了獲取當前登陸用戶工具類、Json和Object的互相轉換的類、jwt工具類
我把工具類粘貼到了博客園http://www.javashuo.com/article/p-znvbvysh-dn.html
AES加密解密工具類、Base64工具是引用博客https://www.jianshu.com/p/f37f8c295057的
關於redis的配置是粘貼http://www.javashuo.com/article/p-zoqgwzgp-db.html的,引用了博主的JedisUtil類
ResponseBean.java
既然想要實現 restful,那咱們要保證每次返回的格式都是相同的,所以創建了一個 ResponseBean 來統一返回的格式。
仿造博客寫了一個 CustomUnauthorizedException.java
主要實現了登錄、新增用戶、經過制定id獲取指定用戶,其中用到了通用mapper進行查詢,剛開始想用前幾天看到jpa搜了下看到有相似的通用mapper。
/**
* JWT過濾
* @return
* @author guxiangdong
* @creed: Talk is cheap,show me the code
* @date 2019/9/25 13:59
*/
@RestController
@RequestMapping("/users")
@PropertySource("classpath:config.properties")
public class UserController {
/**
* RefreshToken過時時間
*/
@Value("${refreshTokenExpireTime}")
private String refreshTokenExpireTime;
private final UserUtil userUtil;
private final IUserService userService;
@Autowired
public UserController(UserUtil userUtil, IUserService userService) {
this.userUtil = userUtil;
this.userService = userService;
}
/**
* 獲取用戶列表
*/
@GetMapping
@RequiresPermissions(logical = Logical.AND, value = {"user:view"})
public ResponseBean user(@Validated BaseDto baseDto) {
if (baseDto.getPage() == null || baseDto.getRows() == null) {
baseDto.setPage(1);
baseDto.setRows(10);
}
PageHelper.startPage(baseDto.getPage(), baseDto.getRows());
List<UsersDto> usersDtos = userService.selectAll();
PageInfo<UsersDto> selectPage = new PageInfo<UsersDto>(usersDtos);
if (usersDtos == null || usersDtos.size() <= 0) {
throw new CustomException("查詢失敗(Query Failure)");
}
Map<String, Object> result = new HashMap<String, Object>(16);
result.put("count", selectPage.getTotal());
result.put("data", selectPage.getList());
return new ResponseBean(HttpStatus.OK.value(), "查詢成功(Query was successful)", result);
}
/**
* 登陸受權
*/
@PostMapping("/login")
public ResponseBean login(@Validated(UserLoginValidGroup.class) @RequestBody UsersDto usersDto, HttpServletResponse httpServletResponse) {
// 查詢數據庫中的賬號信息
UsersDto usersDtoTemp = new UsersDto();
usersDtoTemp.setAccount(usersDto.getAccount());
usersDtoTemp = userService.selectOne(usersDtoTemp);
if (usersDtoTemp == null) {
throw new CustomUnauthorizedException("該賬號不存在(The account does not exist.)");
}
// 密碼進行AES解密
String key = AesCipherUtil.deCrypto(usersDtoTemp.getPsword());
// 由於密碼加密是以賬號+密碼的形式進行加密的,因此解密後的對比是賬號+密碼
if (key.equals(usersDto.getAccount() + usersDto.getPsword())) {
// 清除可能存在的Shiro權限信息緩存
if (JedisUtil.exists(Constant.PREFIX_SHIRO_CACHE + usersDto.getAccount())) {
JedisUtil.delKey(Constant.PREFIX_SHIRO_CACHE + usersDto.getAccount());
}
// 設置RefreshToken,時間戳爲當前時間戳,直接設置便可(不用先刪後設,會覆蓋已有的RefreshToken)
String currentTimeMillis = String.valueOf(System.currentTimeMillis());
JedisUtil.setObject(Constant.PREFIX_SHIRO_REFRESH_TOKEN + usersDto.getAccount(), currentTimeMillis, Integer.parseInt(refreshTokenExpireTime));
// // 從Header中Authorization返回AccessToken,時間戳爲當前時間戳
String token = JwtUtil.sign(usersDto.getAccount(), currentTimeMillis);
httpServletResponse.setHeader("Authorization", token);
httpServletResponse.setHeader("Access-Control-Expose-Headers", "Authorization");
return new ResponseBean(HttpStatus.OK.value(), "登陸成功(Login Success.)", null);
} else {
throw new CustomUnauthorizedException("賬號或密碼錯誤(Account or Password Error.)");
}
}
/**
* 測試登陸
*/
@GetMapping("/article")
public ResponseBean article() {
Subject subject = SecurityUtils.getSubject();
// 登陸了返回true
if (subject.isAuthenticated()) {
return new ResponseBean(HttpStatus.OK.value(), "您已經登陸了(You are already logged in)", null);
} else {
return new ResponseBean(HttpStatus.OK.value(), "你是遊客(You are guest)", null);
}
}
/**
* 獲取指定用戶
*/
@GetMapping("/{id}")
@RequiresPermissions(logical = Logical.AND, value = {"user:view"})
public ResponseBean findById(@PathVariable("id") Integer id) {
UsersDto usersDto = userService.selectByPrimaryKey(id);
if (usersDto == null) {
throw new CustomException("查詢失敗(Query Failure)");
}
return new ResponseBean(HttpStatus.OK.value(), "查詢成功(Query was successful)", usersDto);
}
/**
* 新增用戶
*/
@PostMapping("/add")
@RequiresPermissions(logical = Logical.AND, value = {"user:edit"})
public ResponseBean add(@Validated(UserEditValidGroup.class) @RequestBody UsersDto UsersDto ,HttpServletResponse httpServletResponse) {
// 判斷當前賬號是否存在
UsersDto userDtoTemp = new UsersDto();
userDtoTemp.setAccount(UsersDto.getAccount());
userDtoTemp = userService.selectOne(userDtoTemp);
if (userDtoTemp != null && StringUtil.isNotBlank(userDtoTemp.getPsword())) {
throw new CustomUnauthorizedException("該賬號已存在(Account exist.)");
}
UsersDto.setRegTime(new Date());
// 密碼以賬號+密碼的形式進行AES加密
if (UsersDto.getPsword().length() > Constant.PASSWORD_MAX_LEN) {
throw new CustomException("密碼最多8位(Psword up to 8 bits.)");
}
String key = AesCipherUtil.enCrypto(UsersDto.getAccount() + UsersDto.getPsword());
UsersDto.setPsword(key);
int count = userService.insert(UsersDto);
if (count <= 0) {
throw new CustomException("新增失敗(Insert Failure)");
}
return new ResponseBean(HttpStatus.OK.value(), "新增成功(Insert Success)", UsersDto);
}
}
JWTToken 差很少就是 Shiro 用戶名密碼的載體。由於先後端分離,服務器無需保存用戶狀態,因此不須要 RememberMe 這類功能,實現下 AuthenticationToken 接口便可
實現Realm
realm 的用於處理用戶是否合法的這一塊,須要咱們本身實現。
這裏要重寫supports方法否則會報錯
AuthenticationInfo表明了用戶的角色信息集合,AuthorizationInfo表明了角色的權限信息集合,PrincipalCollection是一個身份集合,
全部的請求都會先通過 Filter,因此咱們繼承官方的 BasicHttpAuthenticationFilter ,而且重寫鑑權的方法代碼的執行流程 preHandle(對跨域提供支持) -> isAccessAllowed(登入用戶和遊客看到的內容是不一樣的,若是在這裏返回了false,請求會被直接攔截,用戶看不到任何東西。因此在這裏返回true,Controller中能夠經過 subject.isAuthenticated() 來判斷用戶是否登入若是有些資源只有登入用戶才能訪問,只須要在方法上面加上 @RequiresAuthentication 註解便可可是這樣作有一個缺點,就是不可以對GET,POST等請求進行分別過濾鑑權(由於咱們重寫了官方的方法),但實際上對應用影響不大) -> isLoginAttempt (檢測Header裏面是否包含Authorization字段,有就進行Token登陸認證受權)-> executeLogin (進行登錄認證受權)。
@Configuration
public class ShiroConfig {
/**
* 配置使用自定義Realm,關閉Shiro自帶的session
* 詳情見文檔
http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
*/
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
@Bean("securityManager")
public DefaultWebSecurityManager defaultWebSecurityManager(UsersRealm usersRealm) {
DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
// 使用自定義Realm
defaultWebSecurityManager.setRealm(usersRealm);
// 關閉Shiro自帶的session
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
defaultWebSecurityManager.setSubjectDAO(subjectDAO);
// 設置自定義Cache緩存
defaultWebSecurityManager.setCacheManager(new CustomCacheManager());
return defaultWebSecurityManager;
}
* Shiro自帶攔截器配置規則
* rest:好比/admins/user/**=rest[user],根據請求的方法,至關於/admins/user/**=perms[user:method] ,其中method爲post,get,delete等
* port:好比/admins/user/**=port[8081],當請求的url的端口不是8081是跳轉到schemal://serverName:8081?queryString,其中schmal是協議http或https等,serverName是你訪問的host,8081是url配置裏port的端口,queryString是你訪問的url裏的?後面的參數
* perms:好比/admins/user/**=perms[user:add:*],perms參數能夠寫多個,多個時必須加上引號,而且參數之間用逗號分割,好比/admins/user/**=perms["user:add:*,user:modify:*"],當有多個參數時必須每一個參數都經過才經過,想當於isPermitedAll()方法
* roles:好比/admins/user/**=roles[admin],參數能夠寫多個,多個時必須加上引號,而且參數之間用逗號分割,當有多個參數時,好比/admins/user/**=roles["admin,guest"],每一個參數經過纔算經過,至關於hasAllRoles()方法。//要實現or的效果看http://zgzty.blog.163.com/blog/static/83831226201302983358670/
* anon:好比/admins/**=anon 沒有參數,表示能夠匿名使用
* authc:好比/admins/user/**=authc表示須要認證才能使用,沒有參數
* authcBasic:好比/admins/user/**=authcBasic沒有參數表示httpBasic認證
* ssl:好比/admins/user/**=ssl沒有參數,表示安全的url請求,協議爲https
* user:好比/admins/user/**=user沒有參數表示必須存在用戶,當登入操做時不作檢查
* 詳情見文檔 http://shiro.apache.org/web.html#urls-
*/
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
Map<String, Filter> filterMap = new HashMap<>(16);
filterMap.put("jwt", new JwtFilter());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);
LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>(16);
filterChainDefinitionMap.put("/**", "jwt");
factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
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;
}
}
Redis部分尚未太理解,大部分是仿照
https://blog.csdn.net/qq_31897023/article/details/89082541
大概過程
寫配置文件config.properties(Redis的配置屬性)
JedisConfig.java(JedisPool啓動配置Bean,原本是直接將JedisUtil注入爲Bean,每次使用直接@Autowired注入使用便可,可是在重寫Shiro的CustomCache沒法注入JedisUtil,因此就改爲靜態注入JedisPool鏈接池,JedisUtil工具類仍是直接調用靜態方法,無需@Autowired注入 取自https://blog.csdn.net/W_Z_W_888/article/details/79979103
引用JedisUtil(Jedis工具類)、 StringUtil、 SerializableUtil
重寫Shiro的Cache保存讀取和Shiro的Cache管理器
重寫Shiro的Cache保存讀取和Shiro的Cache管理器
CustomCache.java(Cache保存讀取)
/**
* 重寫Shiro的Cache保存讀取
*/
public class CustomCache<K,V> implements Cache<K,V> {
/**
* redis-key-前綴-shiro:cache:
*/
public final static String PREFIX_SHIRO_CACHE = "shiro:cache:";
/**
* 過時時間-5分鐘
*/
private static final Integer EXPIRE_TIME = 5 * 60 * 1000;
/**
* 緩存的key名稱獲取爲shiro:cache:account
* @param key
* @return java.lang.String
* @author Wang926454
* @date 2018/9/4 18:33
*/
private String getKey(Object key){
return PREFIX_SHIRO_CACHE + JWTUtil.getUsername(key.toString());
}
/**
* 獲取緩存
*/
@Override
public Object get(Object key) throws CacheException {
if(!JedisUtil.exists(this.getKey(key))){
return null;
}
return JedisUtil.getObject(this.getKey(key));
}
/**
* 保存緩存
*/
@Override
public Object put(Object key, Object value) throws CacheException {
// 設置Redis的Shiro緩存
return JedisUtil.setObject(this.getKey(key), value, EXPIRE_TIME);
}
/**
* 移除緩存
*/
@Override
public Object remove(Object key) throws CacheException {
if(!JedisUtil.exists(this.getKey(key))){
return null;
}
JedisUtil.delKey(this.getKey(key));
return null;
}
/**
* 清空全部緩存
*/
@Override
public void clear() throws CacheException {
JedisUtil.getJedis().flushDB();
}
/**
* 緩存的個數
*/
@Override
public int size() {
Long size = JedisUtil.getJedis().dbSize();
return size.intValue();
}
/**
* 獲取全部的key
*/
@Override
public Set keys() {
Set<byte[]> keys = JedisUtil.getJedis().keys(new String("*").getBytes());
Set<Object> set = new HashSet<Object>();
for (byte[] bs : keys) {
set.add(SerializableUtil.unserializable(bs));
}
return set;
}
/**
* 獲取全部的value
*/
@Override
public Collection values() {
Set keys = this.keys();
List<Object> values = new ArrayList<Object>();
for (Object key : keys) {
values.add(JedisUtil.getObject(this.getKey(key)));
}
return values;
}
}
CustomCacheManager.java(緩存(Cache)管理器)
/**
* 重寫Shiro緩存管理器
*/
public class CustomCacheManager implements CacheManager {
@Override
public <K, V> Cache<K, V> getCache(String s) throws CacheException {
return new CustomCache<K,V>();
}
}
最後在Shiro的配置Bean裏設置咱們重寫的緩存(Cache)管理器
登陸認證經過後返回AccessToken信息(在AccessToken中保存當前的時間戳和賬號),同時在Redis中設置一條以賬號爲Key,Value爲當前時間戳(登陸時間)的RefreshToken,如今認證時必須AccessToken沒失效以及Redis存在所對應的RefreshToken,且RefreshToken時間戳和AccessToken信息中時間戳一致纔算認證經過,這樣能夠作到JWT的可控性,若是從新登陸獲取了新的AccessToken,舊的AccessToken就認證不了,由於Redis中所存放的的RefreshToken時間戳信息只會和最新的AccessToken信息中攜帶的時間戳一致,這樣每一個用戶就只能使用最新的AccessToken認證,Redis的RefreshToken也能夠用來判斷用戶是否在線,若是刪除Redis的某個RefreshToken,那這個RefreshToken所對應的AccessToken以後也沒法經過認證。
自己AccessToken的過時時間爲5分鐘(配置文件可配置),RefreshToken過時時間爲30分鐘(配置文件可配置),當登陸後時間過了5分鐘以後,當前AccessToken便會過時失效,再次帶上AccessToken訪問JWT會拋出TokenExpiredException異常說明Token過時,開始判斷是否要進行AccessToken刷新,首先Redis查詢RefreshToken是否存在,以及時間戳和過時AccessToken所攜帶的時間戳是否一致,若是存在且一致就進行AccessToken刷新,過時時間爲5分鐘(配置文件可配置),時間戳爲當前最新時間戳,同時也設置RefreshToken中的時間戳爲當前最新時間戳,刷新過時時間從新爲30分鐘過時(配置文件可配置),最終將刷新的AccessToken存放在Response的Header中的Authorization字段返
回(前端進行獲取替換,下次用新的AccessToken進行訪問)
先設置Content-Type爲application/json
而後填寫請求參數賬號密碼信息,進行請求訪問,請求訪問成功
點擊查看Header信息的Authorization屬性便是Token字段
訪問須要權限的請求將Token字段放在Header信息的Authorization屬性訪問便可
新增用戶也須要權限的請求將Token字段放在Header信息的Authorization屬性訪問便可
最後,目前本身最大問題就是能夠理解代碼,可是一旦本身動手作的時候就犯難,過於依賴網上搬磚,業餘須要多加聯繫。
[顧祥東1]配置Redis